From 80321f3f2950d28afa480c9b37353eea3763527e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Mon, 11 May 2026 22:40:18 +0200 Subject: [PATCH] Preserve Discord webhook images on entry edits --- .vscode/settings.json | 3 ++ discord_rss_bot/feeds.py | 74 +++++++++++++++++++++++++++++++++++++--- tests/test_feeds.py | 73 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8bd0ea9..d91153b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,13 +2,16 @@ "cSpell.words": [ "autoexport", "botuser", + "domcontentloaded", "Genshins", "healthcheck", "Hoyolab", + "KHTML", "levelname", "Lovinator", "markdownified", "markdownify", + "networkidle", "pipx", "pyproject", "thead", diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index 6897087..44d20a2 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -100,7 +100,6 @@ class JsonResponseLike(Protocol): MAX_DISCORD_UPLOAD_BYTES: int = 8 * 1024 * 1024 -JPEG_QUALITY_STEPS: tuple[int, ...] = (85, 70, 55, 40) SENT_WEBHOOKS_TAG: str = "sent_webhooks" SAVE_SENT_WEBHOOKS_TAG: str = "save_sent_webhooks" MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = ( @@ -357,6 +356,69 @@ def hash_webhook_payload(payload: JsonObject) -> str: return hashlib.sha256(normalized_payload.encode()).hexdigest() +def json_object_or_empty(value: JsonValue) -> JsonObject: + """Return a JSON object value or an empty object.""" + return cast("JsonObject", value) if isinstance(value, dict) else {} + + +def json_list_or_empty(value: JsonValue) -> list[JsonValue]: + """Return a JSON list value or an empty list.""" + return cast("list[JsonValue]", value) if isinstance(value, list) else [] + + +def has_media_url(value: JsonValue) -> bool: + """Return whether an embed media field has a usable URL.""" + return isinstance(value, dict) and isinstance(value.get("url"), str) and bool(value["url"]) + + +def preserve_previous_embed_media(payload: JsonObject, previous_payload: JsonObject) -> JsonObject: + """Keep existing embed image fields when an entry update cannot extract a replacement. + + Returns: + JsonObject: Payload with previous embed media restored when the update lacks replacement media. + """ + embeds: list[JsonValue] = json_list_or_empty(payload.get("embeds")) + previous_embeds: list[JsonValue] = json_list_or_empty(previous_payload.get("embeds")) + if not embeds or not previous_embeds: + return payload + + merged_payload: JsonObject = cast("JsonObject", json.loads(json.dumps(payload, default=str))) + merged_embeds: list[JsonValue] = json_list_or_empty(merged_payload.get("embeds")) + + for index, embed_value in enumerate(merged_embeds): + if index >= len(previous_embeds) or not isinstance(embed_value, dict): + continue + + previous_embed: JsonObject = json_object_or_empty(previous_embeds[index]) + embed: JsonObject = cast("JsonObject", embed_value) + for media_key in ("image", "thumbnail"): + previous_media: JsonValue = previous_embed.get(media_key) + if has_media_url(previous_media) and not has_media_url(embed.get(media_key)): + embed[media_key] = previous_media + + return merged_payload + + +def get_webhook_message_edit_payload(payload: JsonObject, record: SentWebhookRecord) -> JsonObject: + """Return the payload to PATCH to Discord for a saved message edit. + + Returns: + JsonObject: Payload suitable for a Discord message edit request. + """ + previous_payload: JsonObject = json_object_or_empty(record.get("payload")) + edit_payload: JsonObject = preserve_previous_embed_media(payload, previous_payload) + + previous_embeds: list[JsonValue] = json_list_or_empty(previous_payload.get("embeds")) + if edit_payload.get("embeds") == [] and not previous_embeds: + edit_payload.pop("embeds", None) + + previous_attachments: list[JsonValue] = json_list_or_empty(previous_payload.get("attachments")) + if edit_payload.get("attachments") == [] and not previous_attachments: + edit_payload.pop("attachments", None) + + return edit_payload + + def json_value_to_int(value: JsonValue, default: int = 0) -> int: """Convert a simple JSON scalar to int. @@ -661,14 +723,18 @@ def update_sent_webhook_record_for_entry( reader, use_default_message_on_empty=True, ) - payload: JsonObject = get_webhook_message_payload(webhook) + payload: JsonObject = preserve_previous_embed_media( + get_webhook_message_payload(webhook), + json_object_or_empty(record.get("payload")), + ) + edit_payload: JsonObject = get_webhook_message_edit_payload(payload, record) payload_hash: str = hash_webhook_payload(payload) if payload_hash == record.get("payload_hash"): return record, False, False now: str = datetime.datetime.now(tz=datetime.UTC).isoformat() try: - response = edit_sent_webhook_message(webhook_url_value, message_id_value, webhook, payload) + response = edit_sent_webhook_message(webhook_url_value, message_id_value, webhook, edit_payload) except (AssertionError, RequestException, httpx.HTTPError, OSError, ValueError) as e: logger.exception("Failed to edit Discord webhook message %s for entry %s", message_id_value, entry.id) return ( @@ -837,7 +903,7 @@ def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) -> len(screenshot_bytes), ) - for quality in JPEG_QUALITY_STEPS: + for quality in (85, 70, 55, 40): jpeg_bytes = capture_full_page_screenshot( entry_link, screenshot_layout=screenshot_layout, diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 2d26900..06863f0 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -7,6 +7,7 @@ from datetime import UTC from datetime import datetime from pathlib import Path from typing import LiteralString +from typing import cast from unittest.mock import MagicMock from unittest.mock import patch @@ -1040,13 +1041,15 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload( response.json.return_value = {"id": "message-3"} mock_edit_sent_webhook_message.return_value = response - updated_count = feeds.update_sent_webhooks_for_modified_entries( + updated_count: int = feeds.update_sent_webhooks_for_modified_entries( reader, [("https://example.com/feed.xml", "entry-3")], ) assert updated_count == 1 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] assert isinstance(records, list) assert isinstance(records[0], dict) @@ -1058,6 +1061,74 @@ def test_update_sent_webhooks_for_modified_entries_edits_changed_payload( assert not records[0]["last_error"] +@patch("discord_rss_bot.feeds.edit_sent_webhook_message") +@patch("discord_rss_bot.feeds.create_webhook_for_entry") +def test_update_sent_webhook_record_preserves_existing_embed_image_when_updated_entry_has_no_image( + mock_create_webhook_for_entry: MagicMock, + mock_edit_sent_webhook_message: MagicMock, +) -> None: + previous_image: JsonObject = { + "url": "https://example.com/original-image.jpg", + "proxy_url": None, + "height": None, + "width": None, + } + old_payload: JsonObject = { + "content": "", + "embeds": [{"description": "Old summary", "image": previous_image, "thumbnail": None}], + "attachments": [], + } + record: feeds.SentWebhookRecord = { + "feed_url": "https://example.com/feed.xml", + "entry_id": "entry-4", + "webhook_url": "https://discord.com/api/webhooks/123/abc", + "message_id": "message-4", + "payload": old_payload, + "payload_hash": feeds.hash_webhook_payload(old_payload), + "update_count": 0, + } + + entry = MagicMock() + entry.id = "entry-4" + entry.title = "New title" + entry.link = "https://example.com/entry-4" + entry.updated = datetime(2026, 5, 8, tzinfo=UTC) + entry.feed.url = "https://example.com/feed.xml" + entry.feed.title = "Example feed" + + reader = MagicMock() + webhook = MagicMock() + webhook.json = { + "content": "", + "embeds": [{"description": "New summary", "image": None, "thumbnail": None}], + "attachments": [], + } + mock_create_webhook_for_entry.return_value = (webhook, "embed") + + response = MagicMock() + response.status_code = 200 + response.text = '{"id": "message-4"}' + response.json.return_value = {"id": "message-4"} + mock_edit_sent_webhook_message.return_value = response + + updated_record, record_changed, message_was_edited = feeds.update_sent_webhook_record_for_entry( + reader, + entry, + record, + ) + + assert record_changed is True + assert message_was_edited is True + edit_payload = mock_edit_sent_webhook_message.call_args.args[3] + assert isinstance(edit_payload["embeds"], list) + assert edit_payload["embeds"][0]["image"] == previous_image + assert isinstance(updated_record["payload"], dict) + updated_payload = cast("JsonObject", updated_record["payload"]) + updated_embeds = cast("list[JsonObject]", updated_payload["embeds"]) + assert updated_embeds[0]["image"] == previous_image + assert updated_record["payload_hash"] == feeds.hash_webhook_payload(updated_payload) + + def test_update_feeds_and_collect_modified_entries_only_returns_modified_entries() -> None: class StubReader: def __init__(self) -> None: