Preserve Discord webhook images on entry edits
All checks were successful
Test and build Docker image / docker (push) Successful in 1m38s
All checks were successful
Test and build Docker image / docker (push) Successful in 1m38s
This commit is contained in:
parent
36d55566fc
commit
80321f3f29
3 changed files with 145 additions and 5 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue