From 3346994763fc486c8c180ce42c76298a19e5247e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 12 May 2026 20:31:12 +0200 Subject: [PATCH] Allow images to be between 0..10 --- .vscode/settings.json | 6 +- discord_rss_bot/feeds.py | 108 ++++++++++---- discord_rss_bot/main.py | 49 ++++++- discord_rss_bot/static/styles.css | 9 ++ discord_rss_bot/templates/embed.html | 6 +- discord_rss_bot/templates/feed.html | 205 +++++++++++++++++++++++---- tests/test_feeds.py | 148 +++++++++++++++++-- tests/test_main.py | 47 +++++- 8 files changed, 500 insertions(+), 78 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 99abd42..658befd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,25 +3,29 @@ "argnames", "argvalues", "autoexport", + "autoplay", "botuser", "DISCORDTIMESTAMPPLACEHOLDER", "domcontentloaded", "Genshins", "healthcheck", "Hoyolab", + "HTMX", "KHTML", "levelname", "Lovinator", "markdownified", "markdownify", "networkidle", + "overwritable", "pipx", "pyproject", "Skulbladi", "thead", "thelovinator", "ttvdrops", - "uvicorn" + "uvicorn", + "youtu" ], "python.analysis.typeCheckingMode": "basic" } diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index 0ff8615..68ce295 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -64,6 +64,7 @@ if TYPE_CHECKING: from collections.abc import Iterable from reader._types import EntryData + from reader.types import JSONType logger: logging.Logger = logging.getLogger(__name__) @@ -103,13 +104,6 @@ class JsonResponseLike(Protocol): ... -MAX_DISCORD_UPLOAD_BYTES: int = 8 * 1024 * 1024 -MAX_MEDIA_GALLERY_ITEMS: int = 10 -MESSAGE_FLAG_IS_COMPONENTS_V2: int = 1 << 15 -TTVDROPS_HOST: str = "ttvdrops.lovinator.space" -TTVDROPS_BASE_URL: str = f"https://{TTVDROPS_HOST}" -SENT_WEBHOOKS_TAG: str = "sent_webhooks" -SAVE_SENT_WEBHOOKS_TAG: str = "save_sent_webhooks" MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = ( "allowed_mentions", "applied_tags", @@ -290,6 +284,41 @@ def get_screenshot_layout(reader: Reader, feed: Feed) -> ScreenshotLayout: return "desktop" +def coerce_media_gallery_image_limit(value: JsonValue) -> int: # noqa: PLR0911 + """Return the supported media gallery image limit for a stored tag value.""" + if isinstance(value, bool): + return 1 + if isinstance(value, int): + return min(max(value, 0), 10) + if isinstance(value, str) and value.strip().lower() in {"1", "first", "first_image", "first-only"}: + return 1 + if isinstance(value, str) and value.strip().lower() in {"0", "none", "no_images", "off", "disabled"}: + return 0 + if isinstance(value, str): + try: + parsed_value: int = int(value.strip()) + except ValueError: + return 1 + return min(max(parsed_value, 0), 10) + return 1 + + +def get_feed_media_gallery_image_limit(reader: Reader, feed: Feed | str) -> int: + """Resolve how many feed images should be sent in Discord media galleries. + + Returns: + The configured image limit, normalized to a supported Discord gallery size. + """ + feed_url: str = str(getattr(feed, "url", feed)) + try: + value = cast("JsonValue", reader.get_tag(feed, "media_gallery_image_limit", 1)) + except ReaderError: + logger.exception("Error getting %s tag for feed: %s", "media_gallery_image_limit", feed_url) + return 1 + + return coerce_media_gallery_image_limit(value) + + def feed_saves_sent_webhooks(reader: Reader, feed: Feed | str) -> bool: """Return whether sent Discord webhook messages should be stored for a feed. @@ -297,9 +326,9 @@ def feed_saves_sent_webhooks(reader: Reader, feed: Feed | str) -> bool: """ feed_url: str = feed.url if isinstance(feed, Feed) else str(feed) try: - value = cast("JsonValue", reader.get_tag(feed, SAVE_SENT_WEBHOOKS_TAG, True)) + value = cast("JsonValue", reader.get_tag(feed, "save_sent_webhooks", True)) except ReaderError: - logger.exception("Error getting %s tag for feed: %s", SAVE_SENT_WEBHOOKS_TAG, feed_url) + logger.exception("Error getting %s tag for feed: %s", "save_sent_webhooks", feed_url) return True if isinstance(value, bool): @@ -317,7 +346,7 @@ def get_sent_webhook_records(reader: Reader) -> list[SentWebhookRecord]: Returns: list[SentWebhookRecord]: Saved sent webhook records. """ - raw_records = cast("JsonValue", reader.get_tag((), SENT_WEBHOOKS_TAG, [])) + raw_records = cast("JsonValue", reader.get_tag((), "sent_webhooks", [])) if not isinstance(raw_records, list): return [] @@ -329,7 +358,7 @@ def get_sent_webhook_records(reader: Reader) -> list[SentWebhookRecord]: def save_sent_webhook_records(reader: Reader, records: list[SentWebhookRecord]) -> None: """Save sent webhook records to the global reader tag.""" - reader.set_tag((), SENT_WEBHOOKS_TAG, records) # pyright: ignore[reportArgumentType] + reader.set_tag((), "sent_webhooks", records) # pyright: ignore[reportArgumentType] def get_webhook_request_payload(webhook: DiscordWebhook) -> JsonObject: @@ -441,7 +470,7 @@ def get_webhook_message_edit_payload(payload: JsonObject, record: SentWebhookRec if edit_payload.get("attachments") == [] and not previous_attachments: edit_payload.pop("attachments", None) - if json_value_to_int(edit_payload.get("flags")) & MESSAGE_FLAG_IS_COMPONENTS_V2: + if json_value_to_int(edit_payload.get("flags")) & 1 << 15: edit_payload.pop("content", None) edit_payload.pop("embeds", None) edit_payload.pop("poll", None) @@ -1071,7 +1100,7 @@ def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) -> ) screenshot_extension: str = "png" - if screenshot_bytes and len(screenshot_bytes) > MAX_DISCORD_UPLOAD_BYTES: + if screenshot_bytes and len(screenshot_bytes) > 8 * 1024 * 1024: logger.info( "Screenshot for entry %s is too large as PNG (%d bytes). Trying JPEG compression.", entry.id, @@ -1097,7 +1126,7 @@ def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) -> screenshot_bytes = jpeg_bytes screenshot_extension = "jpg" - if len(screenshot_bytes) <= MAX_DISCORD_UPLOAD_BYTES: + if len(screenshot_bytes) <= 8 * 1024 * 1024: break if screenshot_bytes is None: @@ -1108,7 +1137,7 @@ def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) -> ) return create_text_webhook(webhook_url, entry, reader=reader, use_default_message_on_empty=True) - if len(screenshot_bytes) > MAX_DISCORD_UPLOAD_BYTES: + if len(screenshot_bytes) > 8 * 1024 * 1024: logger.warning( "Screenshot for entry %s is still too large after compression (%d bytes). Falling back to text message.", entry.id, @@ -1331,7 +1360,7 @@ def add_unique_media_gallery_item( image_url: str, *, description: str, - limit: int = MAX_MEDIA_GALLERY_ITEMS, + limit: int = 10, ) -> None: """Append a valid media gallery item while preserving order and uniqueness.""" clean_image_url: str = image_url.strip() @@ -1352,7 +1381,7 @@ def normalize_ttvdrops_media_url(image_url: str) -> str: clean_image_url: str = image_url.strip() if not clean_image_url: return "" - return urljoin(TTVDROPS_BASE_URL, clean_image_url) + return urljoin("https://ttvdrops.lovinator.space/", clean_image_url) def get_ttvdrops_campaign_api_url(entry: Entry) -> str: @@ -1368,7 +1397,7 @@ def get_ttvdrops_campaign_api_url(entry: Entry) -> str: continue parsed_url = urlparse(str(candidate_url)) - if parsed_url.netloc.lower() != TTVDROPS_HOST: + if parsed_url.netloc.lower() != "ttvdrops.lovinator.space": continue if re.fullmatch(r"/twitch/api/v1/campaigns/[^/]+/?", parsed_url.path): @@ -1465,25 +1494,34 @@ def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]: return extract_ttvdrops_media_gallery_items(response_json) -def get_entry_media_gallery_items(entry: Entry, custom_embed: CustomEmbed) -> list[JsonObject]: +def get_entry_media_gallery_items( + entry: Entry, + custom_embed: CustomEmbed, + *, + image_limit: int = 10, +) -> list[JsonObject]: """Return items for a Discord Media Gallery component. Returns: Media Gallery items capped to Discord's item limit. """ + image_limit = coerce_media_gallery_image_limit(image_limit) + if image_limit == 0: + return [] + media_items: list[JsonObject] = [] ttvdrops_media_items: list[JsonObject] = fetch_ttvdrops_campaign_media_items(entry) if ttvdrops_media_items: - return ttvdrops_media_items[:MAX_MEDIA_GALLERY_ITEMS] + return ttvdrops_media_items[:image_limit] description: str = entry.title or entry.id - for image_url in get_image_urls(entry.summary, entry.content, limit=MAX_MEDIA_GALLERY_ITEMS): + for image_url in get_image_urls(entry.summary, entry.content, limit=image_limit): add_unique_media_gallery_item(media_items, image_url, description=description) add_unique_media_gallery_item(media_items, custom_embed.image_url, description=description) add_unique_media_gallery_item(media_items, custom_embed.thumbnail_url, description=description) - return media_items[:MAX_MEDIA_GALLERY_ITEMS] + return media_items[:image_limit] def truncate_component_text(content: str) -> str: @@ -1544,7 +1582,7 @@ def create_media_gallery_component(media_items: list[JsonObject]) -> JsonObject: "media": {"url": media_item["url"]}, "description": media_item["description"], } - for media_item in media_items[:MAX_MEDIA_GALLERY_ITEMS] + for media_item in media_items[:10] if isinstance(media_item.get("url"), str) and isinstance(media_item.get("description"), str) ], } @@ -1570,13 +1608,13 @@ def create_components_v2_webhook( ] return DiscordWebhook( url=webhook_url, - flags=MESSAGE_FLAG_IS_COMPONENTS_V2, + flags=1 << 15, components=components, rate_limit_retry=True, ) -def create_embed_webhook( # noqa: C901 +def create_embed_webhook( # noqa: C901, PLR0912 webhook_url: str, entry: Entry, reader: Reader, @@ -1596,7 +1634,16 @@ def create_embed_webhook( # noqa: C901 # Get the embed data from the database. custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader) - media_gallery_items: list[JsonObject] = get_entry_media_gallery_items(entry, custom_embed) + media_gallery_image_limit: int = get_feed_media_gallery_image_limit(reader, feed) + if media_gallery_image_limit == 0: + custom_embed.image_url = "" + custom_embed.thumbnail_url = "" + + media_gallery_items: list[JsonObject] = get_entry_media_gallery_items( + entry, + custom_embed, + image_limit=media_gallery_image_limit, + ) if media_gallery_items: return create_components_v2_webhook(webhook_url, entry, custom_embed, media_gallery_items) @@ -1886,7 +1933,14 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: reader.set_tag(clean_feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType] # Store sent Discord message ids by default so modified feed entries can edit the original webhook message. - reader.set_tag(clean_feed_url, SAVE_SENT_WEBHOOKS_TAG, True) # pyright: ignore[reportArgumentType] + reader.set_tag(clean_feed_url, "save_sent_webhooks", True) # pyright: ignore[reportArgumentType] + + # Keep the existing delivery behavior for new feeds unless changed from the feed page. + reader.set_tag( + clean_feed_url, + "media_gallery_image_limit", + cast("JSONType", 1), + ) # This is the default message that will be sent to Discord. reader.set_tag(clean_feed_url, "custom_message", default_custom_message) # pyright: ignore[reportArgumentType] diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 5ebfbcd..35d0ac0 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -51,12 +51,13 @@ 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 replace_tags_in_text_message from discord_rss_bot.custom_message import save_embed -from discord_rss_bot.feeds import SAVE_SENT_WEBHOOKS_TAG from discord_rss_bot.feeds import SentWebhookRecord +from discord_rss_bot.feeds import coerce_media_gallery_image_limit from discord_rss_bot.feeds import create_feed from discord_rss_bot.feeds import extract_domain from discord_rss_bot.feeds import feed_saves_sent_webhooks from discord_rss_bot.feeds import get_feed_delivery_mode +from discord_rss_bot.feeds import get_feed_media_gallery_image_limit from discord_rss_bot.feeds import get_screenshot_layout from discord_rss_bot.feeds import get_sent_webhook_records from discord_rss_bot.feeds import send_entry_to_discord @@ -71,6 +72,7 @@ from discord_rss_bot.filter.evaluator import evaluate_entry_filters from discord_rss_bot.filter.evaluator import get_entry_decision_key from discord_rss_bot.filter.evaluator import get_entry_fields from discord_rss_bot.filter.evaluator import get_filter_values_from_reader +from discord_rss_bot.filter.evaluator import has_filter_values from discord_rss_bot.git_backup import commit_state_change from discord_rss_bot.git_backup import get_backup_path from discord_rss_bot.is_url_valid import is_url_valid @@ -1346,12 +1348,44 @@ async def post_set_feed_save_sent_webhooks( except FeedNotFoundError as e: raise HTTPException(status_code=404, detail="Feed not found") from e - reader.set_tag(clean_feed_url, SAVE_SENT_WEBHOOKS_TAG, should_save) # pyright: ignore[reportArgumentType] + reader.set_tag(clean_feed_url, "save_sent_webhooks", should_save) # pyright: ignore[reportArgumentType] action: str = "Enable" if should_save else "Disable" commit_state_change(reader, f"{action} sent webhook storage for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) +@app.post("/set_feed_media_gallery_image_limit") +async def post_set_feed_media_gallery_image_limit( + feed_url: Annotated[str, Form()], + image_limit: Annotated[int, Form()], + reader: Annotated[Reader, Depends(get_reader_dependency)], +) -> RedirectResponse: + """Set whether a feed sends one image or a full media gallery. + + Returns: + RedirectResponse: Redirect to the feed page. + + Raises: + HTTPException: If the feed does not exist. + """ + clean_feed_url: str = feed_url.strip() + clean_image_limit: int = coerce_media_gallery_image_limit(image_limit) + clean_image_limit_json: JSONType = cast("JSONType", clean_image_limit) + + try: + reader.get_feed(clean_feed_url) + except FeedNotFoundError as e: + raise HTTPException(status_code=404, detail="Feed not found") from e + + reader.set_tag( + clean_feed_url, + "media_gallery_image_limit", + clean_image_limit_json, + ) + commit_state_change(reader, f"Set media gallery image limit to {clean_image_limit} for {clean_feed_url}") + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + + @app.post("/set_update_interval") async def post_set_update_interval( feed_url: Annotated[str, Form()], @@ -1620,6 +1654,9 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915 current_webhook_name = hook.get("name", "").strip() break + has_blacklist_filters: bool = has_filter_values(get_filter_values_from_reader(reader, feed, "blacklist")) + has_whitelist_filters: bool = has_filter_values(get_filter_values_from_reader(reader, feed, "whitelist")) + # Only show button if more than 10 entries. total_entries: int = reader.get_entry_counts(feed=feed).total or 0 is_show_more_entries_button_visible: bool = total_entries > entries_per_page @@ -1668,6 +1705,10 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915 "webhooks": webhooks, "current_webhook_url": current_webhook_url, "current_webhook_name": current_webhook_name, + "has_blacklist_filters": has_blacklist_filters, + "has_whitelist_filters": has_whitelist_filters, + "media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed), + "max_media_gallery_items": 10, "save_sent_webhooks": feed_saves_sent_webhooks(reader, feed), } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -1728,6 +1769,10 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915 "webhooks": webhooks, "current_webhook_url": current_webhook_url, "current_webhook_name": current_webhook_name, + "has_blacklist_filters": has_blacklist_filters, + "has_whitelist_filters": has_whitelist_filters, + "media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed), + "max_media_gallery_items": 10, "save_sent_webhooks": feed_saves_sent_webhooks(reader, feed), } return templates.TemplateResponse(request=request, name="feed.html", context=context) diff --git a/discord_rss_bot/static/styles.css b/discord_rss_bot/static/styles.css index 65567b3..e701e42 100644 --- a/discord_rss_bot/static/styles.css +++ b/discord_rss_bot/static/styles.css @@ -18,6 +18,11 @@ body { max-width: 120px; } +.image-limit-control { + min-width: 12rem; + max-width: 28rem; +} + .screenshot-requirement { color: #9c9c9c; font-size: 0.9rem; @@ -65,6 +70,10 @@ body { word-break: break-word; } +.feed-summary-list { + padding-left: 1.15rem; +} + .sent-webhooks__entry, .sent-webhooks__preview { min-width: 14rem; diff --git a/discord_rss_bot/templates/embed.html b/discord_rss_bot/templates/embed.html index af800e3..b50807e 100644 --- a/discord_rss_bot/templates/embed.html +++ b/discord_rss_bot/templates/embed.html @@ -236,10 +236,12 @@ - + +
+ 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. +
diff --git a/discord_rss_bot/templates/feed.html b/discord_rss_bot/templates/feed.html index ec15805..889ddf5 100644 --- a/discord_rss_bot/templates/feed.html +++ b/discord_rss_bot/templates/feed.html @@ -30,6 +30,18 @@ Text {% endif %} + {% if not "youtube.com/feeds/videos.xml" in feed.url %} + + Images: + {% if media_gallery_image_limit == 0 %} + No images + {% elif media_gallery_image_limit == 1 %} + First image only + {% else %} + Up to {{ media_gallery_image_limit }} images + {% endif %} + + {% endif %} {% if delivery_mode == "screenshot" %} Screenshot layout: @@ -42,6 +54,72 @@ {% endif %} +
+

Feed Summary

+

+ This feed + {% if feed.updates_enabled %} + will send new entries + {% else %} + is paused and will not send new entries + {% endif %} + {% if current_webhook_name %} + to {{ current_webhook_name }} + {% elif current_webhook_url %} + to a webhook that is no longer saved + {% else %} + after a webhook is attached + {% endif %} + as + {% if delivery_mode == "embed" %} + an embed. + {% elif delivery_mode == "screenshot" %} + a screenshot of the entry link page in {{ screenshot_layout }} mode. + {% else %} + a text message. + {% endif %} +

+
    +
  • + Update interval: + {% if feed_interval %} + {{ feed_interval }} minutes. + {% else %} + {{ global_interval }} minutes because of the global default. + {% endif %} +
  • + {% if delivery_mode == "embed" %} +
  • + Embed images: + {% if media_gallery_image_limit == 0 %} + none. + {% elif media_gallery_image_limit == 1 %} + first image only. + {% else %} + up to {{ media_gallery_image_limit }} images. + {% endif %} +
  • + {% elif delivery_mode == "screenshot" %} +
  • Screenshot layout: {{ screenshot_layout }}.
  • + {% endif %} +
  • + Filters: + {% if has_blacklist_filters and has_whitelist_filters %} + whitelist and blacklist are active; blacklist wins when both match. + {% elif has_blacklist_filters %} + blacklist is active. + {% elif has_whitelist_filters %} + whitelist is active. + {% else %} + no whitelist or blacklist filters are configured. + {% endif %} +
  • +
  • + Updating the Discord webhook when the feed entry changes is + {{ 'enabled.' if save_sent_webhooks else 'disabled.' }} +
  • +
+
{% if feed.last_exception %} + + {% if not "youtube.com/feeds/videos.xml" in feed.url %} +
+
+

Screenshot Delivery

+ + {% if delivery_mode == "screenshot" %} + Active: + {% if screenshot_layout == "mobile" %} + Mobile + {% else %} + Desktop + {% endif %} + {% else %} + Inactive + {% endif %} + +
+

+ Screenshot delivery sends a full-page screenshot of the entry link instead of the normal + embed or text message. +

+
+ {% if delivery_mode != "screenshot" %} +
+ +
+ {% else %} +
+ +
{% if screenshot_layout == "mobile" %}
{% endif %} - {% else %} -
- -
-
- -
{% endif %} -
- Screenshot mode requires Chromium to be installed for Playwright. Run uv run playwright install chromium once on this machine. +
+
+ Screenshot mode requires Chromium to be installed for Playwright. Run + uv run playwright install chromium once on this machine. +
+
+
+
+

Image Delivery

+ + {% if media_gallery_image_limit == 0 %} + No images + {% elif media_gallery_image_limit == 1 %} + First image only + {% else %} + Up to {{ media_gallery_image_limit }} images + {% endif %} + +
+

+ Choose 0 to send no entry images, 1 for the first image only, + or up to {{ max_media_gallery_items }} for a Discord media gallery. + This only affects embed delivery. +

+
+ + +
+
+ +
+ 0 + {{ max_media_gallery_items }} +
+
+
- {% endif %} - -
+ + + {% endif %}

Customization

diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 6687631..e852cba 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -338,6 +338,45 @@ def test_get_screenshot_layout_defaults_to_desktop() -> None: assert result == "desktop" +@pytest.mark.parametrize( + ("tag_value", "expected_limit"), + [ + (0, 0), + (1, 1), + (7, 7), + (-1, 0), + (99, 10), + ("first", 1), + ("off", 0), + ("8", 8), + ("unknown", 1), + ], +) +def test_get_feed_media_gallery_image_limit_normalizes_stored_tag( + tag_value: feeds.JsonValue, + expected_limit: int, +) -> None: + reader = MagicMock() + feed = MagicMock() + feed.url = "https://example.com/feed.xml" + reader.get_tag.return_value = tag_value + + result = feeds.get_feed_media_gallery_image_limit(reader, feed) + + assert result == expected_limit + + +def test_get_feed_media_gallery_image_limit_defaults_to_first_image() -> None: + reader = MagicMock() + feed = MagicMock() + feed.url = "https://example.com/feed.xml" + reader.get_tag.side_effect = lambda resource, key, default=None: default # noqa: ARG005 + + result = feeds.get_feed_media_gallery_image_limit(reader, feed) + + assert result == 1 + + def test_create_feed_inherits_global_screenshot_layout() -> None: reader = MagicMock() reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 @@ -374,7 +413,24 @@ def test_create_feed_enables_sent_webhook_tracking_by_default() -> None: create_feed(reader, "https://example.com/feed.xml", "Main") - reader.set_tag.assert_any_call("https://example.com/feed.xml", feeds.SAVE_SENT_WEBHOOKS_TAG, True) + reader.set_tag.assert_any_call("https://example.com/feed.xml", "save_sent_webhooks", True) + + +def test_create_feed_sets_default_media_gallery_image_limit() -> None: + reader = MagicMock() + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "webhooks": [{"name": "Main", "url": "https://discord.com/api/webhooks/123/abc"}], + "screenshot_layout": "desktop", + "delivery_mode": "embed", + }.get(key, default) + + create_feed(reader, "https://example.com/feed.xml", "Main") + + reader.set_tag.assert_any_call( + "https://example.com/feed.xml", + "media_gallery_image_limit", + 1, + ) def test_create_feed_falls_back_to_embed_when_global_delivery_mode_is_invalid() -> None: @@ -581,6 +637,7 @@ def test_create_embed_webhook_uses_media_gallery_for_entry_images( mock_fetch_ttvdrops_campaign_media_items: MagicMock, ) -> None: reader = MagicMock() + reader.get_tag.return_value = 10 entry = MagicMock() entry.id = "entry-1" entry.title = "Entry title" @@ -599,7 +656,7 @@ def test_create_embed_webhook_uses_media_gallery_for_entry_images( webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader) - assert webhook.flags == feeds.MESSAGE_FLAG_IS_COMPONENTS_V2 + assert webhook.flags == 1 << 15 components = get_test_webhook_components(webhook) assert components[0] == { "type": 10, @@ -616,6 +673,69 @@ def test_create_embed_webhook_uses_media_gallery_for_entry_images( ] +@patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items", return_value=[]) +@patch("discord_rss_bot.feeds.replace_tags_in_embed") +def test_create_embed_webhook_can_limit_media_gallery_to_first_image( + mock_replace_tags_in_embed: MagicMock, + mock_fetch_ttvdrops_campaign_media_items: MagicMock, +) -> None: + reader = MagicMock() + reader.get_tag.return_value = 1 + entry = MagicMock() + entry.id = "entry-1" + entry.title = "Entry title" + entry.link = "https://example.com/entry" + entry.summary = '' + entry.content = [ + MagicMock(value=''), + MagicMock(value=''), + ] + entry.feed.url = "https://example.com/feed.xml" + mock_replace_tags_in_embed.return_value = feeds.CustomEmbed(description="Entry body") + + webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader) + + gallery = get_test_webhook_components(webhook)[1] + assert isinstance(gallery, dict) + mock_fetch_ttvdrops_campaign_media_items.assert_called_once_with(entry) + assert gallery["items"] == [ + {"media": {"url": "https://example.com/content-1.jpg"}, "description": "Entry title"}, + ] + + +@patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items", return_value=[]) +@patch("discord_rss_bot.feeds.replace_tags_in_embed") +def test_create_embed_webhook_can_disable_media_images( + mock_replace_tags_in_embed: MagicMock, + mock_fetch_ttvdrops_campaign_media_items: MagicMock, +) -> None: + reader = MagicMock() + reader.get_tag.return_value = 0 + entry = MagicMock() + entry.id = "entry-1" + entry.title = "Entry title" + entry.link = "https://example.com/entry" + entry.summary = '' + entry.content = [MagicMock(value='')] + entry.feed.url = "https://example.com/feed.xml" + mock_replace_tags_in_embed.return_value = feeds.CustomEmbed( + description="Entry body", + image_url="https://example.com/custom-image.jpg", + thumbnail_url="https://example.com/custom-thumbnail.jpg", + ) + + webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader) + + assert "components" not in webhook.json + assert "embeds" in webhook.json + embeds = webhook.json["embeds"] + assert isinstance(embeds, list) + assert isinstance(embeds[0], dict) + assert "image" not in embeds[0] + assert "thumbnail" not in embeds[0] + mock_fetch_ttvdrops_campaign_media_items.assert_not_called() + + @patch("discord_rss_bot.feeds.fetch_ttvdrops_campaign_media_items") @patch("discord_rss_bot.feeds.replace_tags_in_embed") def test_create_embed_webhook_prefers_ttvdrops_reward_images_and_alt_text( @@ -1046,9 +1166,9 @@ def test_execute_webhook_records_sent_webhook_message(mock_send_webhook_message: state: dict[str, feeds.JsonValue] = {} def get_tag(_resource: str | tuple[()], key: str, default: feeds.JsonValue = None) -> feeds.JsonValue: - if key == feeds.SENT_WEBHOOKS_TAG: - return state.get(feeds.SENT_WEBHOOKS_TAG, default) - if key == feeds.SAVE_SENT_WEBHOOKS_TAG: + if key == "sent_webhooks": + return state.get("sent_webhooks", default) + if key == "save_sent_webhooks": return True if key == "webhook": return webhook_url @@ -1083,7 +1203,7 @@ def test_execute_webhook_records_sent_webhook_message(mock_send_webhook_message: execute_webhook(webhook, entry, reader) - records = state[feeds.SENT_WEBHOOKS_TAG] + records = state["sent_webhooks"] assert isinstance(records, list) assert len(records) == 1 assert isinstance(records[0], dict) @@ -1104,7 +1224,7 @@ def test_execute_webhook_does_not_record_when_feed_tracking_disabled(mock_send_w webhook_url = "https://discord.com/api/webhooks/123/abc" reader = MagicMock() reader.get_tag.side_effect = lambda _resource, key, default=None: { - feeds.SAVE_SENT_WEBHOOKS_TAG: False, + "save_sent_webhooks": False, "webhook": webhook_url, }.get(key, default) @@ -1139,7 +1259,7 @@ def test_send_webhook_message_posts_components_with_httpx(mock_request: MagicMoc ] webhook = feeds.DiscordWebhook( url="https://discord.com/api/webhooks/123/abc?thread_id=456", - flags=feeds.MESSAGE_FLAG_IS_COMPONENTS_V2, + flags=1 << 15, components=components, ) @@ -1150,7 +1270,7 @@ def test_send_webhook_message_posts_components_with_httpx(mock_request: MagicMoc assert mock_request.call_args.args == ("POST", "https://discord.com/api/webhooks/123/abc") assert mock_request.call_args.kwargs["json"] == { "components": components, - "flags": feeds.MESSAGE_FLAG_IS_COMPONENTS_V2, + "flags": 1 << 15, } assert mock_request.call_args.kwargs["params"] == { "thread_id": "456", @@ -1185,7 +1305,7 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload( webhook_url = "https://discord.com/api/webhooks/123/abc" old_payload: JsonObject = {"content": "Old title", "embeds": [], "attachments": []} state: dict[str, feeds.JsonValue] = { - feeds.SENT_WEBHOOKS_TAG: [ + "sent_webhooks": [ { "feed_url": "https://example.com/feed.xml", "entry_id": "entry-3", @@ -1199,9 +1319,9 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload( } def get_tag(_resource: str | tuple[()], key: str, default: feeds.JsonValue = None) -> feeds.JsonValue: - if key == feeds.SENT_WEBHOOKS_TAG: - return state[feeds.SENT_WEBHOOKS_TAG] - if key == feeds.SAVE_SENT_WEBHOOKS_TAG: + if key == "sent_webhooks": + return state["sent_webhooks"] + if key == "save_sent_webhooks": return True return default @@ -1240,7 +1360,7 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload( mock_edit_sent_webhook_message.assert_called_once() edit_payload = mock_edit_sent_webhook_message.call_args.args[3] assert edit_payload == {"content": "New title"} - records = state[feeds.SENT_WEBHOOKS_TAG] + records = state["sent_webhooks"] assert isinstance(records, list) assert isinstance(records[0], dict) assert isinstance(records[0]["payload"], dict) diff --git a/tests/test_main.py b/tests/test_main.py index 347e9df..b98416c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -229,6 +229,12 @@ def test_get() -> None: response: Response = client.get(url="/feed", params={"feed_url": encoded_feed_url(feed_url)}) assert response.status_code == 200, f"/feed failed: {response.text}" + assert "Feed Summary" in response.text + assert "This feed" in response.text + assert "Screenshot Delivery" in response.text + assert "Image Delivery" in response.text + assert 'type="range"' in response.text + assert 'max="10"' in response.text response: Response = client.get(url="/") assert response.status_code == 200, f"/ failed: {response.text}" @@ -524,6 +530,8 @@ def test_c3kay_feed_delivery_mode_toggle_routes_update_stored_tags() -> None: assert reader.get_tag(c3kay_feed_url, "delivery_mode") == "screenshot" assert reader.get_tag(c3kay_feed_url, "screenshot_layout") == "mobile" assert reader.get_tag(c3kay_feed_url, "should_send_embed") is False + assert "Disable screenshot delivery" in response.text + assert "Send embed instead of screenshot" not in response.text response = client.post(url="/use_embed", data={"feed_url": c3kay_feed_url}) assert response.status_code == 200, f"Failed to set embed mode: {response.text}" @@ -561,7 +569,42 @@ def test_set_feed_save_sent_webhooks_route_updates_stored_tag() -> None: ) assert response.status_code == 303, f"/set_feed_save_sent_webhooks failed: {response.text}" - assert stub_reader.tags[stub_reader.feed.url, feeds.SAVE_SENT_WEBHOOKS_TAG] is False + assert stub_reader.tags[stub_reader.feed.url, "save_sent_webhooks"] is False + finally: + app.dependency_overrides = {} + + +def test_set_feed_media_gallery_image_limit_route_updates_stored_tag() -> None: + @dataclass(slots=True) + class DummyFeed: + url: str + title: str + + class StubReader: + def __init__(self) -> None: + self.feed = DummyFeed(url="https://example.com/feed.xml", title="Example") + self.tags: dict[tuple[str, str], int] = {} + + def get_feed(self, feed_url: str) -> DummyFeed: + assert feed_url == self.feed.url + return self.feed + + def set_tag(self, resource: str, key: str, value: int) -> None: + self.tags[resource, key] = value + + stub_reader = StubReader() + app.dependency_overrides[get_reader_dependency] = lambda: stub_reader + + try: + with patch("discord_rss_bot.main.commit_state_change"): + response: Response = client.post( + url="/set_feed_media_gallery_image_limit", + data={"feed_url": stub_reader.feed.url, "image_limit": "7"}, + follow_redirects=False, + ) + + assert response.status_code == 303, f"/set_feed_media_gallery_image_limit failed: {response.text}" + assert stub_reader.tags[stub_reader.feed.url, "media_gallery_image_limit"] == 7 finally: app.dependency_overrides = {} @@ -585,7 +628,7 @@ def test_sent_webhooks_view_shows_saved_records() -> None: key: str, default: feeds.JsonValue = None, ) -> feeds.JsonValue: - if resource == () and key == feeds.SENT_WEBHOOKS_TAG: + if resource == () and key == "sent_webhooks": return [ { "feed_url": sent_feed_url,