From 85f25263f39e8ee116ce55abff2dcbeb5c2f376f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 2 Jul 2026 04:40:38 +0200 Subject: [PATCH] Add webhook text length stuff --- discord_rss_bot/feeds.py | 128 ++++++++++++++--- discord_rss_bot/main.py | 66 +++++++++ discord_rss_bot/settings.py | 4 + discord_rss_bot/templates/feed.html | 34 +++++ discord_rss_bot/templates/settings.html | 24 +++- tests/test_feeds.py | 177 +++++++++++++++++++++++- tests/test_git_backup.py | 20 +++ tests/test_main.py | 56 ++++++++ tests/test_settings.py | 42 ++++++ 9 files changed, 525 insertions(+), 26 deletions(-) diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index d3fcc7c..ffdbfb3 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -301,6 +301,38 @@ def get_screenshot_layout(reader: Reader, feed: Feed) -> ScreenshotLayout: return "desktop" +def coerce_webhook_text_length_limit(value: JsonValue) -> int: + """Return the supported webhook text length limit for a stored tag value.""" + if isinstance(value, bool): + return 4000 + if isinstance(value, int): + return min(max(value, 1), 4000) + if isinstance(value, str): + try: + parsed_value: int = int(value.strip()) + except ValueError: + return 4000 + return min(max(parsed_value, 1), 4000) + return 4000 + + +def get_feed_webhook_text_length_limit(reader: Reader, feed: Feed | str) -> int: + """Resolve how much webhook text a feed may send to Discord. + + Returns: + The configured webhook text length limit, normalized to a supported Discord length. + """ + feed_url: str = str(getattr(feed, "url", feed)) + try: + value = reader.get_tag(feed, "webhook_text_length_limit", 4000) + + except ReaderError: + logger.exception("Error getting %s tag for feed: %s", "webhook_text_length_limit", feed_url) + return 4000 + + return coerce_webhook_text_length_limit(value) + + 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): @@ -1090,7 +1122,11 @@ def create_text_webhook( if not webhook_message: webhook_message = "No message found." - webhook_message = truncate_webhook_message(webhook_message) + webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, entry.feed) + webhook_message = truncate_webhook_message( + webhook_message, + max_content_length=webhook_text_length_limit, + ) return DiscordWebhook(url=webhook_url, content=webhook_message, rate_limit_retry=True) @@ -1345,21 +1381,26 @@ def send_discord_quest_notification(entry: Entry, webhook_url: str, reader: Read logger.info("No quest notification found in entry: %s", entry.id) -def set_description(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None: +def set_description( + custom_embed: CustomEmbed, + discord_embed: DiscordEmbed, + *, + max_description_length: int = 2000, +) -> None: """Set the description of the embed. Args: custom_embed (custom_message.CustomEmbed): The custom embed to get the description from. discord_embed (DiscordEmbed): The Discord embed to set the description on. + max_description_length: The maximum embed description length to send. """ - # Its actually 2048, but we will use 2000 to be safe. - max_description_length: int = 2000 + # Discord allows 2048, but we keep a small safety margin by default. embed_description: str = custom_embed.description - embed_description = ( - f"{embed_description[:max_description_length]}..." - if len(embed_description) > max_description_length - else embed_description - ) + if len(embed_description) > max_description_length: + if max_description_length <= 3: # noqa: PLR2004 + embed_description = embed_description[:max_description_length] + else: + embed_description = f"{embed_description[: max_description_length - 3]}..." discord_embed.set_description(embed_description) if embed_description else None @@ -1550,19 +1591,29 @@ def get_entry_media_gallery_items( return media_items[:image_limit] -def truncate_component_text(content: str) -> str: +def truncate_component_text( + content: str, + *, + max_text_display_length: int = 4000, +) -> str: """Trim a Text Display component to a conservative Discord-safe length. Returns: Original or truncated component text. """ - max_text_display_length: int = 4000 if len(content) <= max_text_display_length: return content + if max_text_display_length <= 3: # noqa: PLR2004 + return content[:max_text_display_length] return f"{content[: max_text_display_length - 3]}..." -def get_component_text_display_content(custom_embed: CustomEmbed, entry: Entry) -> str: +def get_component_text_display_content( + custom_embed: CustomEmbed, + entry: Entry, + *, + max_text_length: int = 4000, +) -> str: """Build markdown text for a Components V2 Text Display. Returns: @@ -1592,7 +1643,7 @@ def get_component_text_display_content(custom_embed: CustomEmbed, entry: Entry) fallback_text = f"[{fallback_text}]({entry.link})" parts.append(fallback_text) - return truncate_component_text("\n\n".join(parts)) + return truncate_component_text("\n\n".join(parts), max_text_display_length=max_text_length) def create_media_gallery_component(media_items: list[JsonObject]) -> JsonObject: @@ -1619,6 +1670,8 @@ def create_components_v2_webhook( entry: Entry, custom_embed: CustomEmbed, media_items: list[JsonObject], + *, + max_text_length: int, ) -> DiscordWebhook: """Create a Components V2 webhook with text and a media gallery. @@ -1628,7 +1681,11 @@ def create_components_v2_webhook( components: list[JsonValue] = [ { "type": 10, - "content": get_component_text_display_content(custom_embed, entry), + "content": get_component_text_display_content( + custom_embed, + entry, + max_text_length=max_text_length, + ), }, create_media_gallery_component(media_items), ] @@ -1661,6 +1718,7 @@ def create_embed_webhook( # noqa: C901, PLR0912 # Get the embed data from the database. custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader) media_gallery_image_limit: int = get_feed_media_gallery_image_limit(reader, feed) + webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, feed) if media_gallery_image_limit == 0: custom_embed.image_url = "" custom_embed.thumbnail_url = "" @@ -1671,11 +1729,21 @@ def create_embed_webhook( # noqa: C901, PLR0912 image_limit=media_gallery_image_limit, ) if media_gallery_items: - return create_components_v2_webhook(webhook_url, entry, custom_embed, media_gallery_items) + return create_components_v2_webhook( + webhook_url, + entry, + custom_embed, + media_gallery_items, + max_text_length=webhook_text_length_limit, + ) discord_embed: DiscordEmbed = DiscordEmbed() - set_description(custom_embed=custom_embed, discord_embed=discord_embed) + set_description( + custom_embed=custom_embed, + discord_embed=discord_embed, + max_description_length=min(webhook_text_length_limit, 2000), + ) set_title(custom_embed=custom_embed, discord_embed=discord_embed) custom_embed_author_url: str | None = custom_embed.author_url @@ -1889,20 +1957,28 @@ def should_send_embed_check(reader: Reader, entry: Entry) -> bool: return get_entry_delivery_mode(reader, entry) == "embed" -def truncate_webhook_message(webhook_message: str) -> str: +def truncate_webhook_message( + webhook_message: str, + *, + max_content_length: int = 4000, +) -> str: """Truncate the webhook message if it is too long. Args: webhook_message (str): The webhook message to truncate. + max_content_length: The maximum number of characters Discord should receive. Returns: str: The truncated webhook message. """ - max_content_length: int = 4000 - if len(webhook_message) > max_content_length: - half_length = (max_content_length - 3) // 2 # Subtracting 3 for the "..." in the middle - webhook_message = f"{webhook_message[:half_length]}...{webhook_message[-half_length:]}" - return webhook_message + if len(webhook_message) <= max_content_length: + return webhook_message + if max_content_length <= 3: # noqa: PLR2004 + return webhook_message[:max_content_length] + + head_length = (max_content_length - 3) // 2 + tail_length = max_content_length - 3 - head_length + return f"{webhook_message[:head_length]}...{webhook_message[-tail_length:]}" def get_raw_autodiscover_links(reader: Reader, feed_url: str) -> object | None: @@ -1995,6 +2071,14 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: "media_gallery_image_limit", cast("JSONType", 1), ) + global_webhook_text_length_limit: int = coerce_webhook_text_length_limit( + cast("JsonValue", reader.get_tag((), "webhook_text_length_limit", 4000)), + ) + reader.set_tag( + clean_feed_url, + "webhook_text_length_limit", + cast("JSONType", global_webhook_text_length_limit), + ) # 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 00a03ad..746cd3e 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -54,11 +54,13 @@ from discord_rss_bot.custom_message import save_embed from discord_rss_bot.feeds import FeedUpdateError from discord_rss_bot.feeds import SentWebhookRecord from discord_rss_bot.feeds import coerce_media_gallery_image_limit +from discord_rss_bot.feeds import coerce_webhook_text_length_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_feed_webhook_text_length_limit from discord_rss_bot.feeds import get_screenshot_layout from discord_rss_bot.feeds import get_sent_webhook_records from discord_rss_bot.feeds import send_entry_to_discord @@ -1477,6 +1479,38 @@ async def post_set_feed_media_gallery_image_limit( return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) +@app.post("/set_feed_webhook_text_length_limit") +async def post_set_feed_webhook_text_length_limit( + feed_url: Annotated[str, Form()], + text_length_limit: Annotated[int, Form()], + reader: Annotated[Reader, Depends(get_reader_dependency)], +) -> RedirectResponse: + """Set the maximum webhook text length for a feed. + + Returns: + RedirectResponse: Redirect to the feed page. + + Raises: + HTTPException: If the feed does not exist. + """ + clean_feed_url: str = feed_url.strip() + clean_text_length_limit: int = coerce_webhook_text_length_limit(text_length_limit) + clean_text_length_limit_json: JSONType = cast("JSONType", clean_text_length_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, + "webhook_text_length_limit", + clean_text_length_limit_json, + ) + commit_state_change(reader, f"Set webhook text length limit to {clean_text_length_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()], @@ -1681,6 +1715,28 @@ async def post_set_global_delivery_mode( return RedirectResponse(url="/settings", status_code=303) +@app.post("/set_global_webhook_text_length_limit") +async def post_set_global_webhook_text_length_limit( + text_length_limit: Annotated[int, Form()], + reader: Annotated[Reader, Depends(get_reader_dependency)], +) -> RedirectResponse: + """Set the global default webhook text length limit for newly added feeds. + + Args: + text_length_limit: The max webhook text length. + reader: The Reader instance. + + Returns: + RedirectResponse: Redirect to the settings page. + """ + clean_text_length_limit: int = coerce_webhook_text_length_limit(text_length_limit) + clean_text_length_limit_json: JSONType = cast("JSONType", clean_text_length_limit) + + reader.set_tag((), "webhook_text_length_limit", clean_text_length_limit_json) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Set global webhook text length limit to {clean_text_length_limit}") + return RedirectResponse(url="/settings", status_code=303) + + @app.get("/add", response_class=HTMLResponse) def get_add( request: Request, @@ -1796,6 +1852,8 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915 "has_whitelist_filters": has_whitelist_filters, "media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed), "max_media_gallery_items": 10, + "webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed), + "max_webhook_text_length_limit": 4000, "save_sent_webhooks": feed_saves_sent_webhooks(reader, feed), } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -1860,6 +1918,8 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915 "has_whitelist_filters": has_whitelist_filters, "media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed), "max_media_gallery_items": 10, + "webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed), + "max_webhook_text_length_limit": 4000, "save_sent_webhooks": feed_saves_sent_webhooks(reader, feed), } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -2071,6 +2131,10 @@ async def get_settings( if global_delivery_mode not in {"embed", "text"}: global_delivery_mode = "embed" + global_webhook_text_length_limit: int = coerce_webhook_text_length_limit( + reader.get_tag((), "webhook_text_length_limit", 4000) + ) + # Get all feeds with their intervals feeds: Iterable[Feed] = reader.get_feeds() feed_intervals = [] @@ -2094,6 +2158,8 @@ async def get_settings( "global_interval": global_interval, "global_delivery_mode": global_delivery_mode, "global_screenshot_layout": global_screenshot_layout, + "global_webhook_text_length_limit": global_webhook_text_length_limit, + "max_webhook_text_length_limit": 4000, "feed_intervals": feed_intervals, } return templates.TemplateResponse(request=request, name="settings.html", context=context) diff --git a/discord_rss_bot/settings.py b/discord_rss_bot/settings.py index 2daf6d6..4aaba42 100644 --- a/discord_rss_bot/settings.py +++ b/discord_rss_bot/settings.py @@ -85,4 +85,8 @@ def get_reader(custom_location: Path | None = None) -> Reader: if reader.get_tag((), "delivery_mode", None) is None: reader.set_tag((), "delivery_mode", "embed") # pyright: ignore[reportArgumentType] + # Set the default webhook text length limit for new feeds if not already configured. + if reader.get_tag((), "webhook_text_length_limit", None) is None: + reader.set_tag((), "webhook_text_length_limit", 4000) # pyright: ignore[reportArgumentType] + return reader diff --git a/discord_rss_bot/templates/feed.html b/discord_rss_bot/templates/feed.html index 889ddf5..76c8434 100644 --- a/discord_rss_bot/templates/feed.html +++ b/discord_rss_bot/templates/feed.html @@ -52,6 +52,7 @@ {% endif %} {% endif %} + Text limit: {{ webhook_text_length_limit }} chars
@@ -102,6 +103,7 @@ {% elif delivery_mode == "screenshot" %}
  • Screenshot layout: {{ screenshot_layout }}.
  • {% endif %} +
  • Webhook text is truncated to {{ webhook_text_length_limit }} characters.
  • Filters: {% if has_blacklist_filters and has_whitelist_filters %} @@ -284,6 +286,38 @@
  • {% endif %} +
    +
    +

    Text Delivery

    + + Max {{ webhook_text_length_limit }} characters + +
    +

    + Limit message length. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters. +

    +
    + +
    +
    + + +
    + +
    +
    +

    Customization

    diff --git a/discord_rss_bot/templates/settings.html b/discord_rss_bot/templates/settings.html index 1759738..a37432a 100644 --- a/discord_rss_bot/templates/settings.html +++ b/discord_rss_bot/templates/settings.html @@ -3,7 +3,7 @@ Settings | discord-rss-bot {% endblock title %} {% block description %} - Adjust default update intervals, delivery modes, and screenshot layout for feeds managed by your bot. + Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot. {% endblock description %} {% block content %}
    @@ -78,6 +78,28 @@
    +
    +
    + +
    + + +
    +
    + New feeds inherit this value. Existing feeds keep their current + per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters. +
    +
    +
    diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 564e545..793fcbf 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -379,6 +379,43 @@ def test_get_feed_media_gallery_image_limit_defaults_to_first_image() -> None: assert result == 1 +@pytest.mark.parametrize( + ("tag_value", "expected_limit"), + [ + (1, 1), + (25, 25), + (-1, 1), + (99_999, 4000), + ("200", 200), + ("0", 1), + ("unknown", 4000), + ], +) +def test_get_feed_webhook_text_length_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_webhook_text_length_limit(reader, feed) + + assert result == expected_limit + + +def test_get_feed_webhook_text_length_limit_defaults_to_discord_limit() -> 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_webhook_text_length_limit(reader, feed) + + assert result == 4000 + + def test_create_feed_inherits_global_screenshot_layout() -> None: reader = MagicMock() reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 @@ -435,6 +472,41 @@ def test_create_feed_sets_default_media_gallery_image_limit() -> None: ) +def test_create_feed_sets_default_webhook_text_length_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", + "webhook_text_length_limit", + 4000, + ) + + +def test_create_feed_inherits_global_webhook_text_length_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", + "webhook_text_length_limit": 2500, + }.get(key, default) + + create_feed(reader, "https://example.com/feed.xml", "Main") + + reader.set_tag.assert_any_call( + "https://example.com/feed.xml", + "webhook_text_length_limit", + 2500, + ) + + def test_create_feed_falls_back_to_embed_when_global_delivery_mode_is_invalid() -> None: reader = MagicMock() reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 @@ -664,6 +736,32 @@ def test_create_screenshot_webhook_falls_back_to_text_on_failure( ) +@patch("discord_rss_bot.feeds.replace_tags_in_text_message") +def test_create_text_webhook_uses_feed_text_length_limit(mock_replace_tags_in_text_message: MagicMock) -> None: + mock_replace_tags_in_text_message.return_value = "start-" + ("x" * 40) + "-end" + + entry = MagicMock() + entry.feed.url = "https://example.com/feed.xml" + reader = MagicMock() + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "custom_message": "{{entry_title}}", + "webhook_text_length_limit": 20, + }.get(key, default) + + webhook = feeds.create_text_webhook( + "https://discord.com/api/webhooks/123/abc", + entry, + reader, + use_default_message_on_empty=False, + ) + + assert webhook.content is not None + assert len(webhook.content) == 20 + assert webhook.content.startswith("start-") + assert webhook.content.endswith("-end") + assert "..." in webhook.content + + @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_uses_media_gallery_for_entry_images( @@ -671,7 +769,10 @@ 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 + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "media_gallery_image_limit": 10, + "webhook_text_length_limit": 4000, + }.get(key, default) entry = MagicMock() entry.id = "entry-1" entry.title = "Entry title" @@ -707,6 +808,38 @@ 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_uses_feed_text_length_limit_for_media_gallery( + mock_replace_tags_in_embed: MagicMock, + mock_fetch_ttvdrops_campaign_media_items: MagicMock, +) -> None: + reader = MagicMock() + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "media_gallery_image_limit": 10, + "webhook_text_length_limit": 20, + }.get(key, default) + 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="x" * 100) + + webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader) + + assert webhook.flags == 1 << 15 + components = get_test_webhook_components(webhook) + text_component = components[0] + assert isinstance(text_component, dict) + assert isinstance(text_component["content"], str) + assert len(text_component["content"]) == 20 + assert text_component["content"].endswith("...") + mock_fetch_ttvdrops_campaign_media_items.assert_called_once_with(entry) + + @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( @@ -714,7 +847,10 @@ def test_create_embed_webhook_can_limit_media_gallery_to_first_image( mock_fetch_ttvdrops_campaign_media_items: MagicMock, ) -> None: reader = MagicMock() - reader.get_tag.return_value = 1 + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "media_gallery_image_limit": 1, + "webhook_text_length_limit": 4000, + }.get(key, default) entry = MagicMock() entry.id = "entry-1" entry.title = "Entry title" @@ -744,7 +880,10 @@ def test_create_embed_webhook_can_disable_media_images( mock_fetch_ttvdrops_campaign_media_items: MagicMock, ) -> None: reader = MagicMock() - reader.get_tag.return_value = 0 + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "media_gallery_image_limit": 0, + "webhook_text_length_limit": 4000, + }.get(key, default) entry = MagicMock() entry.id = "entry-1" entry.title = "Entry title" @@ -770,6 +909,38 @@ def test_create_embed_webhook_can_disable_media_images( mock_fetch_ttvdrops_campaign_media_items.assert_not_called() +@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_uses_feed_text_length_limit_for_regular_embed_description( + mock_replace_tags_in_embed: MagicMock, + mock_fetch_ttvdrops_campaign_media_items: MagicMock, +) -> None: + reader = MagicMock() + reader.get_tag.side_effect = lambda resource, key, default=None: { # noqa: ARG005 + "media_gallery_image_limit": 0, + "webhook_text_length_limit": 20, + }.get(key, default) + entry = MagicMock() + entry.id = "entry-1" + entry.title = "Entry title" + entry.link = "https://example.com/entry" + entry.summary = "" + entry.content = [] + entry.feed.url = "https://example.com/feed.xml" + mock_replace_tags_in_embed.return_value = feeds.CustomEmbed(description="x" * 100) + + webhook = feeds.create_embed_webhook("https://discord.com/api/webhooks/123/abc", entry, reader) + + assert "components" not in webhook.json + embeds = webhook.json.get("embeds") + assert isinstance(embeds, list) + assert isinstance(embeds[0], dict) + assert isinstance(embeds[0].get("description"), str) + assert len(embeds[0]["description"]) == 20 + assert embeds[0]["description"].endswith("...") + 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( diff --git a/tests/test_git_backup.py b/tests/test_git_backup.py index e7303f4..abdd422 100644 --- a/tests/test_git_backup.py +++ b/tests/test_git_backup.py @@ -508,6 +508,26 @@ def test_post_set_global_screenshot_layout_triggers_backup(monkeypatch: pytest.M assert "Set global screenshot layout to mobile" in commit_message +def test_post_set_global_webhook_text_length_limit_triggers_backup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Posting to /set_global_webhook_text_length_limit should trigger a git backup.""" + backup_path: Path = tmp_path / "backup" + monkeypatch.setenv("GIT_BACKUP_PATH", str(backup_path)) + monkeypatch.delenv("GIT_BACKUP_REMOTE", raising=False) + + with patch("discord_rss_bot.main.commit_state_change") as mock_commit: + response = client.post(url="/set_global_webhook_text_length_limit", data={"text_length_limit": "2500"}) + assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}" + mock_commit.assert_called_once() + + call_args = mock_commit.call_args + assert call_args is not None + commit_message: str = call_args[0][1] + assert "Set global webhook text length limit to 2500" in commit_message + + def test_post_custom_message_triggers_backup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Posting to /custom should trigger a git backup.""" backup_path: Path = tmp_path / "backup" diff --git a/tests/test_main.py b/tests/test_main.py index 60ef323..500eb70 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -283,8 +283,13 @@ def test_get() -> None: assert "This feed" in response.text assert "Screenshot Delivery" in response.text assert "Image Delivery" in response.text + assert "Text Delivery" in response.text + assert "main embed" in response.text + assert "2000 characters" in response.text assert 'type="range"' in response.text assert 'max="10"' in response.text + assert 'id="text_length_limit"' in response.text + assert 'max="4000"' in response.text response: Response = client.get(url="/") assert response.status_code == 200, f"/ failed: {response.text}" @@ -603,6 +608,10 @@ def test_settings_page_shows_screenshot_layout_setting() -> None: assert response.status_code == 200, f"/settings failed: {response.text}" assert "Default delivery mode for new feeds" in response.text assert "Default screenshot layout for new feeds" in response.text + assert "Default webhook text limit for new feeds" in response.text + assert "regular embed mode" in response.text + assert "2000 characters" in response.text + assert 'id="global_text_length_limit"' in response.text assert "uv run playwright install chromium" in response.text @@ -615,6 +624,18 @@ def test_set_global_delivery_mode() -> None: assert re.search(r"]*\bselected\b", response.text) +def test_set_global_webhook_text_length_limit() -> None: + response: Response = client.post( + url="/set_global_webhook_text_length_limit", + data={"text_length_limit": "2500"}, + ) + assert response.status_code == 200, f"Failed to set global webhook text length limit: {response.text}" + + response = client.get(url="/settings") + assert response.status_code == 200, f"/settings failed after setting webhook text length limit: {response.text}" + assert 'value="2500"' in response.text + + def test_add_page_shows_global_default_delivery_mode_hint() -> None: response: Response = client.post(url="/set_global_delivery_mode", data={"delivery_mode": "text"}) assert response.status_code == 200, f"Failed to set global delivery mode: {response.text}" @@ -742,6 +763,41 @@ def test_set_feed_media_gallery_image_limit_route_updates_stored_tag() -> None: app.dependency_overrides = {} +def test_set_feed_webhook_text_length_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_webhook_text_length_limit", + data={"feed_url": stub_reader.feed.url, "text_length_limit": "2500"}, + follow_redirects=False, + ) + + assert response.status_code == 303, f"/set_feed_webhook_text_length_limit failed: {response.text}" + assert stub_reader.tags[stub_reader.feed.url, "webhook_text_length_limit"] == 2500 + finally: + app.dependency_overrides = {} + + def test_sent_webhooks_view_shows_saved_records() -> None: @dataclass(slots=True) class DummyFeed: diff --git a/tests/test_settings.py b/tests/test_settings.py index 138a948..af627fa 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -192,6 +192,48 @@ def test_get_reader_preserves_existing_global_delivery_mode() -> None: get_reader.cache_clear() +def test_get_reader_sets_default_global_webhook_text_length_limit() -> None: + """get_reader should initialize global webhook text length limit to 4000 when missing.""" + get_reader.cache_clear() + + with tempfile.TemporaryDirectory() as temp_dir: + Path.mkdir(Path(temp_dir), exist_ok=True) + + custom_loc: pathlib.Path = pathlib.Path(temp_dir, "webhook_text_length_limit_default_db.sqlite") + reader: Reader = get_reader(custom_location=custom_loc) + + webhook_text_length_limit = reader.get_tag((), "webhook_text_length_limit", None) + assert webhook_text_length_limit == 4000, ( + f"Expected default global webhook text length limit to be 4000, got: {webhook_text_length_limit}" + ) + + reader.close() + get_reader.cache_clear() + + +def test_get_reader_preserves_existing_global_webhook_text_length_limit() -> None: + """get_reader should not overwrite an existing global webhook text length limit value.""" + get_reader.cache_clear() + + with tempfile.TemporaryDirectory() as temp_dir: + Path.mkdir(Path(temp_dir), exist_ok=True) + + custom_loc: pathlib.Path = pathlib.Path(temp_dir, "webhook_text_length_limit_existing_db.sqlite") + first_reader: Reader = get_reader(custom_location=custom_loc) + first_reader.set_tag((), "webhook_text_length_limit", 2500) # pyright: ignore[reportArgumentType] + first_reader.close() + get_reader.cache_clear() + + second_reader: Reader = get_reader(custom_location=custom_loc) + webhook_text_length_limit = second_reader.get_tag((), "webhook_text_length_limit", None) + assert webhook_text_length_limit == 2500, ( + f"Expected existing global webhook text length limit to stay 2500, got: {webhook_text_length_limit}" + ) + + second_reader.close() + get_reader.cache_clear() + + def test_get_reader_enables_autodiscover_plugin() -> None: """get_reader should store advertised feed links when HTML parsing fails.""" get_reader.cache_clear()