Preserve Discord webhook images on entry edits
All checks were successful
Test and build Docker image / docker (push) Successful in 1m38s

This commit is contained in:
Joakim Hellsén 2026-05-11 22:40:18 +02:00
commit 80321f3f29
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 145 additions and 5 deletions

View file

@ -2,13 +2,16 @@
"cSpell.words": [ "cSpell.words": [
"autoexport", "autoexport",
"botuser", "botuser",
"domcontentloaded",
"Genshins", "Genshins",
"healthcheck", "healthcheck",
"Hoyolab", "Hoyolab",
"KHTML",
"levelname", "levelname",
"Lovinator", "Lovinator",
"markdownified", "markdownified",
"markdownify", "markdownify",
"networkidle",
"pipx", "pipx",
"pyproject", "pyproject",
"thead", "thead",

View file

@ -100,7 +100,6 @@ class JsonResponseLike(Protocol):
MAX_DISCORD_UPLOAD_BYTES: int = 8 * 1024 * 1024 MAX_DISCORD_UPLOAD_BYTES: int = 8 * 1024 * 1024
JPEG_QUALITY_STEPS: tuple[int, ...] = (85, 70, 55, 40)
SENT_WEBHOOKS_TAG: str = "sent_webhooks" SENT_WEBHOOKS_TAG: str = "sent_webhooks"
SAVE_SENT_WEBHOOKS_TAG: str = "save_sent_webhooks" SAVE_SENT_WEBHOOKS_TAG: str = "save_sent_webhooks"
MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = ( MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = (
@ -357,6 +356,69 @@ def hash_webhook_payload(payload: JsonObject) -> str:
return hashlib.sha256(normalized_payload.encode()).hexdigest() 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: def json_value_to_int(value: JsonValue, default: int = 0) -> int:
"""Convert a simple JSON scalar to int. """Convert a simple JSON scalar to int.
@ -661,14 +723,18 @@ def update_sent_webhook_record_for_entry(
reader, reader,
use_default_message_on_empty=True, 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) payload_hash: str = hash_webhook_payload(payload)
if payload_hash == record.get("payload_hash"): if payload_hash == record.get("payload_hash"):
return record, False, False return record, False, False
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat() now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
try: 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: 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) logger.exception("Failed to edit Discord webhook message %s for entry %s", message_id_value, entry.id)
return ( return (
@ -837,7 +903,7 @@ def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) ->
len(screenshot_bytes), len(screenshot_bytes),
) )
for quality in JPEG_QUALITY_STEPS: for quality in (85, 70, 55, 40):
jpeg_bytes = capture_full_page_screenshot( jpeg_bytes = capture_full_page_screenshot(
entry_link, entry_link,
screenshot_layout=screenshot_layout, screenshot_layout=screenshot_layout,

View file

@ -7,6 +7,7 @@ from datetime import UTC
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import LiteralString from typing import LiteralString
from typing import cast
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch 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"} response.json.return_value = {"id": "message-3"}
mock_edit_sent_webhook_message.return_value = response 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, reader,
[("https://example.com/feed.xml", "entry-3")], [("https://example.com/feed.xml", "entry-3")],
) )
assert updated_count == 1 assert updated_count == 1
mock_edit_sent_webhook_message.assert_called_once() 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[feeds.SENT_WEBHOOKS_TAG]
assert isinstance(records, list) assert isinstance(records, list)
assert isinstance(records[0], dict) 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"] 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: def test_update_feeds_and_collect_modified_entries_only_returns_modified_entries() -> None:
class StubReader: class StubReader:
def __init__(self) -> None: def __init__(self) -> None: