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"