Edit sent Discord webhooks if entry values updates
All checks were successful
Test and build Docker image / docker (push) Successful in 1m48s

This commit is contained in:
Joakim Hellsén 2026-05-09 04:41:50 +02:00
commit 36d55566fc
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
13 changed files with 1313 additions and 141 deletions

View file

@ -14,6 +14,9 @@ from markdownify import markdownify
from discord_rss_bot.is_url_valid import is_url_valid
if TYPE_CHECKING:
from collections.abc import Sequence
from reader import Content
from reader import Entry
from reader import Feed
from reader import Reader
@ -193,7 +196,7 @@ def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
return custom_message.replace("\\n", "\n")
def get_first_image(summary: str | None, content: str | None) -> str: # noqa: C901
def get_first_image(summary: str | None, content: str | Sequence[Content] | None) -> str: # noqa: C901
"""Get image from summary or content.
Args:
@ -204,7 +207,7 @@ def get_first_image(summary: str | None, content: str | None) -> str: # noqa: C
The first image
"""
def extract_string(data: str | list | tuple | None) -> str | None:
def extract_string(data: str | list | tuple | Sequence[Content] | None) -> str | None:
if not data:
return None
if isinstance(data, str):

View file

@ -3,18 +3,23 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import datetime
import hashlib
import json
import logging
import os
import pprint
import re
from collections.abc import Callable
from contextlib import suppress
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from typing import Protocol
from typing import cast
from urllib.parse import ParseResult
from urllib.parse import parse_qs
from urllib.parse import urlparse
import httpx
import tldextract
from discord_webhook import DiscordEmbed
from discord_webhook import DiscordWebhook
@ -32,6 +37,9 @@ from reader import FeedNotFoundError
from reader import Reader
from reader import ReaderError
from reader import StorageError
from reader.types import EntryUpdateStatus
from reader.types import UpdatedFeed
from requests import RequestException
from discord_rss_bot.custom_message import CustomEmbed
from discord_rss_bot.custom_message import get_custom_message
@ -50,6 +58,7 @@ from discord_rss_bot.settings import get_reader
if TYPE_CHECKING:
from collections.abc import Iterable
from reader._types import EntryData
from requests import Response
logger: logging.Logger = logging.getLogger(__name__)
@ -57,9 +66,53 @@ logger: logging.Logger = logging.getLogger(__name__)
type DeliveryMode = Literal["embed", "text", "screenshot"]
type ScreenshotLayout = Literal["desktop", "mobile"]
type ScreenshotFileType = Literal["png", "jpeg"]
type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
type JsonObject = dict[str, JsonValue]
type SentWebhookRecord = dict[str, JsonValue]
type UpdateCallback = Callable[[], UpdatedFeed | None]
class JsonResponseLike(Protocol):
"""Response interface needed for Discord webhook JSON parsing."""
@property
def status_code(self) -> int:
"""HTTP status code."""
...
@property
def text(self) -> str:
"""Response body decoded as text."""
...
@property
def content(self) -> bytes:
"""Raw response body."""
...
def json(self) -> JsonValue:
"""Decode response body as JSON.
Returns:
JsonValue: Decoded JSON response body.
"""
...
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, ...] = (
"allowed_mentions",
"attachments",
"avatar_url",
"content",
"embeds",
"flags",
"tts",
"username",
)
def extract_domain(url: str) -> str: # noqa: PLR0911
@ -137,30 +190,12 @@ def send_entry_to_discord(entry: Entry, reader: Reader) -> str | None:
delivery_mode,
)
# Hoyolab/c3kay feeds use a custom embed only when embed mode is selected.
if delivery_mode == "embed" and is_c3kay_feed(entry.feed.url):
entry_link: str | None = entry.link
if entry_link:
post_id: str | None = extract_post_id_from_hoyolab_url(entry_link)
if post_id:
post_data: dict[str, Any] | None = fetch_hoyolab_post(post_id)
if post_data:
webhook = create_hoyolab_webhook(webhook_url, entry, post_data)
execute_webhook(webhook, entry, reader=reader)
return None
logger.warning(
"Failed to create Hoyolab webhook for feed %s, falling back to regular processing",
entry.feed.url,
)
else:
logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
if delivery_mode == "embed":
webhook: DiscordWebhook = create_embed_webhook(webhook_url, entry, reader=reader)
elif delivery_mode == "screenshot":
webhook = create_screenshot_webhook(webhook_url, entry, reader=reader)
else:
webhook = create_text_webhook(webhook_url, entry, reader=reader, use_default_message_on_empty=False)
webhook, _delivery_mode = create_webhook_for_entry(
webhook_url,
entry,
reader,
use_default_message_on_empty=False,
)
execute_webhook(webhook, entry, reader=reader)
return None
@ -244,6 +279,502 @@ def get_screenshot_layout(reader: Reader, feed: Feed) -> ScreenshotLayout:
return "desktop"
def feed_saves_sent_webhooks(reader: Reader, feed: Feed | str) -> bool:
"""Return whether sent Discord webhook messages should be stored for a feed.
Missing tags default to enabled so existing feeds start tracking editable Discord messages.
"""
feed_url: str = feed.url if isinstance(feed, Feed) else str(feed)
try:
value = cast("JsonValue", reader.get_tag(feed, SAVE_SENT_WEBHOOKS_TAG, True))
except ReaderError:
logger.exception("Error getting %s tag for feed: %s", SAVE_SENT_WEBHOOKS_TAG, feed_url)
return True
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off", "disabled"}
if value is None:
return True
return bool(value)
def get_sent_webhook_records(reader: Reader) -> list[SentWebhookRecord]:
"""Get stored sent webhook records from the global reader tag.
Returns:
list[SentWebhookRecord]: Saved sent webhook records.
"""
raw_records = cast("JsonValue", reader.get_tag((), SENT_WEBHOOKS_TAG, []))
if not isinstance(raw_records, list):
return []
records: list[SentWebhookRecord] = [
cast("SentWebhookRecord", dict(raw_record)) for raw_record in raw_records if isinstance(raw_record, dict)
]
return records
def save_sent_webhook_records(reader: Reader, records: list[SentWebhookRecord]) -> None:
"""Save sent webhook records to the global reader tag."""
reader.set_tag((), SENT_WEBHOOKS_TAG, records) # pyright: ignore[reportArgumentType]
def get_webhook_message_payload(webhook: DiscordWebhook) -> JsonObject:
"""Return the Discord message payload used to compare saved messages.
The discord-webhook object also includes client/runtime fields in `json`; only fields that affect the Discord
message itself are persisted. Empty `content`, `embeds`, and `attachments` are kept so message edits can clear
stale content when a feed changes delivery mode.
Returns:
JsonObject: Normalized Discord message payload.
"""
raw_payload = cast("JsonValue", webhook.json)
if not isinstance(raw_payload, dict):
return {"content": "", "embeds": [], "attachments": []}
payload: JsonObject = {}
webhook_payload = cast("JsonObject", raw_payload)
for key in MESSAGE_PAYLOAD_KEYS:
if key in webhook_payload:
payload[key] = webhook_payload[key]
payload.setdefault("content", "")
payload.setdefault("embeds", [])
payload.setdefault("attachments", [])
return cast("JsonObject", json.loads(json.dumps(payload, default=str)))
def hash_webhook_payload(payload: JsonObject) -> str:
"""Hash a normalized Discord message payload.
Returns:
str: SHA-256 hash of the payload.
"""
normalized_payload: str = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(normalized_payload.encode()).hexdigest()
def json_value_to_int(value: JsonValue, default: int = 0) -> int:
"""Convert a simple JSON scalar to int.
Returns:
int: Converted integer, or default when the value is not scalar-convertible.
"""
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float | str):
try:
return int(value)
except ValueError:
return default
return default
def get_response_json(response: JsonResponseLike) -> JsonObject:
"""Best-effort JSON extraction for requests/httpx response objects.
Returns:
JsonObject: Decoded JSON object, or an empty dict.
"""
try:
response_json = response.json()
except (AttributeError, TypeError, ValueError):
response_text: str = response.text
if not response_text:
response_content: bytes = response.content
if isinstance(response_content, bytes):
response_text = response_content.decode("utf-8", errors="ignore")
if not response_text:
return {}
try:
response_json = json.loads(response_text)
except json.JSONDecodeError:
return {}
return cast("JsonObject", dict(response_json)) if isinstance(response_json, dict) else {}
def get_discord_message_id_from_response(response_json: JsonObject, webhook: DiscordWebhook) -> str:
"""Get the Discord message id from a decoded webhook response.
Returns:
str: Discord message id, or an empty string.
"""
message_id: JsonValue = response_json.get("id")
if isinstance(message_id, str) and message_id:
return message_id
webhook_id: str | None = webhook.id if isinstance(webhook.id, str) else None
return webhook_id if isinstance(webhook_id, str) else ""
def get_discord_message_id(response: JsonResponseLike, webhook: DiscordWebhook) -> str:
"""Get the Discord message id returned by a webhook send/edit response.
Returns:
str: Discord message id, or an empty string.
"""
return get_discord_message_id_from_response(get_response_json(response), webhook)
def get_entry_timestamp(value: datetime.datetime | None) -> str:
"""Return an ISO timestamp when an entry datetime-like value is present.
Returns:
str: ISO timestamp, or an empty string.
"""
if value is not None:
return value.isoformat()
return ""
def upsert_sent_webhook_record(
reader: Reader,
entry: Entry,
webhook_url: str,
webhook: DiscordWebhook,
response: JsonResponseLike,
payload: JsonObject,
) -> None:
"""Store the Discord message id and rendered payload for a successfully sent entry."""
if not feed_saves_sent_webhooks(reader, entry.feed):
return
response_json: JsonObject = get_response_json(response)
message_id: str = get_discord_message_id_from_response(response_json, webhook)
if not message_id:
logger.debug("Discord response did not include a message id for entry %s; not storing webhook.", entry.id)
return
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
payload_hash: str = hash_webhook_payload(payload)
delivery_mode: DeliveryMode = get_entry_delivery_mode(reader, entry)
record: SentWebhookRecord = {
"feed_url": entry.feed.url,
"feed_title": entry.feed.title or "",
"entry_id": entry.id,
"entry_title": entry.title or "",
"entry_link": entry.link or "",
"entry_updated": get_entry_timestamp(entry.updated),
"webhook_url": webhook_url,
"message_id": message_id,
"delivery_mode": delivery_mode,
"payload": payload,
"payload_hash": payload_hash,
"discord_response": response_json,
"response_text": response.text[:5000],
"first_sent_at": now,
"last_sent_at": now,
"last_updated_at": now,
"last_status_code": response.status_code,
"last_error": "",
"update_count": 0,
}
records: list[SentWebhookRecord] = get_sent_webhook_records(reader)
for index, existing_record in enumerate(records):
if (
existing_record.get("feed_url") == entry.feed.url
and existing_record.get("entry_id") == entry.id
and existing_record.get("webhook_url") == webhook_url
):
record["first_sent_at"] = existing_record.get("first_sent_at") or now
record["update_count"] = json_value_to_int(existing_record.get("update_count"))
records[index] = record
save_sent_webhook_records(reader, records)
return
records.append(record)
save_sent_webhook_records(reader, records)
def split_webhook_url_for_message_endpoint(webhook_url: str) -> tuple[str, str | None]:
"""Split a webhook URL into the base webhook endpoint and optional thread id.
Returns:
tuple[str, str | None]: Clean webhook URL and optional thread id.
"""
parsed_url = urlparse(webhook_url)
query = parse_qs(parsed_url.query)
thread_id_values: list[str] = query.get("thread_id", [])
thread_id: str | None = thread_id_values[0].strip() if thread_id_values else None
if not thread_id:
thread_id = None
clean_url: str = parsed_url._replace(query="", fragment="").geturl().rstrip("/")
return clean_url, thread_id
def edit_sent_webhook_message(
webhook_url: str,
message_id: str,
webhook: DiscordWebhook,
payload: JsonObject,
) -> Response | httpx.Response:
"""Edit an already-sent Discord webhook message.
Returns:
Response | httpx.Response: Discord API response.
"""
clean_webhook_url, thread_id = split_webhook_url_for_message_endpoint(webhook_url)
if getattr(webhook, "files", None):
webhook.url = clean_webhook_url
webhook.id = message_id
if thread_id:
webhook.thread_id = thread_id
return webhook.edit()
params: dict[str, str] = {"wait": "true"}
if thread_id:
params["thread_id"] = thread_id
timeout: int | float = cast("int | float", getattr(webhook, "timeout", None) or 30.0)
return httpx.patch(
f"{clean_webhook_url}/messages/{message_id}",
json=payload,
params=params,
timeout=timeout,
)
def create_webhook_for_entry(
webhook_url: str,
entry: Entry,
reader: Reader,
*,
use_default_message_on_empty: bool,
) -> tuple[DiscordWebhook, DeliveryMode]:
"""Create the Discord webhook payload for the entry's effective delivery mode.
Returns:
tuple[DiscordWebhook, DeliveryMode]: Rendered webhook object and delivery mode.
"""
delivery_mode: DeliveryMode = get_entry_delivery_mode(reader, entry)
if delivery_mode == "embed" and is_c3kay_feed(entry.feed.url):
entry_link: str | None = entry.link
if entry_link:
post_id: str | None = extract_post_id_from_hoyolab_url(entry_link)
if post_id:
post_data = fetch_hoyolab_post(post_id)
if post_data:
return create_hoyolab_webhook(webhook_url, entry, post_data), delivery_mode
logger.warning(
"Failed to create Hoyolab webhook for feed %s, falling back to regular processing",
entry.feed.url,
)
else:
logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
if delivery_mode == "embed":
return create_embed_webhook(webhook_url, entry, reader=reader), delivery_mode
if delivery_mode == "screenshot":
return create_screenshot_webhook(webhook_url, entry, reader=reader), delivery_mode
return (
create_text_webhook(
webhook_url,
entry,
reader=reader,
use_default_message_on_empty=use_default_message_on_empty,
),
delivery_mode,
)
def collect_modified_entries_during_update(reader: Reader, update_callback: UpdateCallback) -> list[tuple[str, str]]:
"""Run a reader update call and collect entries whose stored content was modified.
Returns:
list[tuple[str, str]]: Modified entry `(feed_url, entry_id)` pairs.
"""
modified_entries: list[tuple[str, str]] = []
hooks = reader.after_entry_update_hooks
def collect_modified_entry(_reader: Reader, entry: EntryData, status: EntryUpdateStatus) -> None:
status_value: str = getattr(status, "value", str(status))
if status == EntryUpdateStatus.MODIFIED or status_value == EntryUpdateStatus.MODIFIED.value:
modified_entries.append((entry.feed_url, entry.id))
hooks.append(collect_modified_entry)
try:
update_callback()
finally:
with suppress(ValueError):
hooks.remove(collect_modified_entry)
return list(dict.fromkeys(modified_entries))
def update_feeds_and_collect_modified_entries(
reader: Reader,
*,
scheduled: bool,
workers: int,
) -> list[tuple[str, str]]:
"""Update feeds and return reader entries whose stored content was modified.
Returns:
list[tuple[str, str]]: Modified entry `(feed_url, entry_id)` pairs.
"""
return collect_modified_entries_during_update(
reader,
lambda: reader.update_feeds(scheduled=scheduled, workers=workers),
)
def update_feed_and_collect_modified_entries(reader: Reader, feed: Feed | str) -> list[tuple[str, str]]:
"""Update one feed and return reader entries whose stored content was modified.
Returns:
list[tuple[str, str]]: Modified entry `(feed_url, entry_id)` pairs.
"""
return collect_modified_entries_during_update(reader, lambda: reader.update_feed(feed))
def update_sent_webhook_record_for_entry(
reader: Reader,
entry: Entry,
record: SentWebhookRecord,
) -> tuple[SentWebhookRecord, bool, bool]:
"""Edit one saved Discord webhook message record for an updated entry.
Returns:
tuple[SentWebhookRecord, bool, bool]: Updated record, whether it changed, and whether Discord was edited.
"""
webhook_url_value: JsonValue = record.get("webhook_url")
message_id_value: JsonValue = record.get("message_id")
if (
not isinstance(webhook_url_value, str)
or not isinstance(message_id_value, str)
or not webhook_url_value
or not message_id_value
):
return record, False, False
webhook, delivery_mode = create_webhook_for_entry(
webhook_url_value,
entry,
reader,
use_default_message_on_empty=True,
)
payload: JsonObject = get_webhook_message_payload(webhook)
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)
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 (
{
**record,
"last_update_attempt_at": now,
"last_error": str(e),
},
True,
False,
)
status_code: int = response.status_code
response_json: JsonObject = get_response_json(response)
if status_code in {200, 204}:
return (
{
**record,
"feed_title": entry.feed.title or "",
"entry_title": entry.title or "",
"entry_link": entry.link or "",
"entry_updated": get_entry_timestamp(entry.updated),
"delivery_mode": delivery_mode,
"payload": payload,
"payload_hash": payload_hash,
"discord_response": response_json,
"response_text": response.text[:5000],
"last_updated_at": now,
"last_status_code": status_code,
"last_error": "",
"update_count": json_value_to_int(record.get("update_count")) + 1,
},
True,
True,
)
return (
{
**record,
"last_update_attempt_at": now,
"last_status_code": status_code,
"discord_response": response_json,
"response_text": response.text[:5000],
"last_error": response.text[:500],
},
True,
False,
)
def update_sent_webhooks_for_modified_entries( # noqa: C901
reader: Reader,
modified_entries: Iterable[tuple[str, str]],
) -> int:
"""Edit saved Discord webhook messages for modified reader entries.
Returns:
int: Number of Discord messages successfully edited.
"""
modified_entry_keys: set[tuple[str, str]] = set(modified_entries)
if not modified_entry_keys:
return 0
records: list[SentWebhookRecord] = get_sent_webhook_records(reader)
if not records:
return 0
records_changed: bool = False
updated_count: int = 0
for feed_url, entry_id in modified_entry_keys:
matching_record_indexes: list[int] = [
index
for index, record in enumerate(records)
if record.get("feed_url") == feed_url and record.get("entry_id") == entry_id
]
if not matching_record_indexes:
continue
try:
entry: Entry = reader.get_entry((feed_url, entry_id))
except (FeedNotFoundError, EntryNotFoundError):
logger.exception("Saved webhook entry no longer exists: %s %s", feed_url, entry_id)
continue
if not feed_saves_sent_webhooks(reader, entry.feed):
continue
for record_index in matching_record_indexes:
updated_record, record_changed, message_was_edited = update_sent_webhook_record_for_entry(
reader,
entry,
records[record_index],
)
if record_changed:
records[record_index] = updated_record
records_changed = True
if message_was_edited:
updated_count += 1
if records_changed:
save_sent_webhook_records(reader, records)
return updated_count
def create_text_webhook(
webhook_url: str,
entry: Entry,
@ -496,7 +1027,7 @@ def send_discord_quest_notification(entry: Entry, webhook_url: str, reader: Read
content=quest_url,
rate_limit_retry=True,
)
execute_webhook(webhook, entry, reader=reader)
execute_webhook(webhook, entry, reader=reader, save_sent_webhook=False)
# Iterate through the content of the entry
for content in entry.content:
@ -661,7 +1192,7 @@ def set_entry_as_read(reader: Reader, entry: Entry) -> None:
logger.exception("Error setting entry to read: %s", entry.id)
def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: C901, PLR0912
def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None:
"""Send entries to Discord.
If response was not ok, we will log the error and mark the entry as unread, so it will be sent again next time.
@ -675,11 +1206,16 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
# Get the default reader if we didn't get a custom one.
effective_reader: Reader = get_reader() if reader is None else reader
# Check for new entries for every feed.
effective_reader.update_feeds(
# Check for new and modified entries for every feed.
modified_entries: list[tuple[str, str]] = update_feeds_and_collect_modified_entries(
effective_reader,
scheduled=True,
workers=os.cpu_count() or 1,
)
try:
update_sent_webhooks_for_modified_entries(effective_reader, modified_entries)
except (AssertionError, ReaderError, RequestException, httpx.HTTPError, OSError, ValueError):
logger.exception("Failed to update saved Discord webhooks for modified feed entries.")
# Loop through the unread entries.
entries: Iterable[Entry] = effective_reader.get_entries(feed=feed, read=False)
@ -695,42 +1231,17 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
logger.info("No webhook URL found for feed: %s", entry.feed.url)
continue
delivery_mode: DeliveryMode = get_entry_delivery_mode(effective_reader, entry)
if delivery_mode == "embed":
webhook = create_embed_webhook(webhook_url, entry, reader=effective_reader)
elif delivery_mode == "screenshot":
webhook = create_screenshot_webhook(webhook_url, entry, reader=effective_reader)
else:
webhook = create_text_webhook(
webhook_url,
entry,
reader=effective_reader,
use_default_message_on_empty=True,
)
decision = get_entry_filter_decision_from_reader(effective_reader, entry)
if not decision.should_send:
logger.info("Entry was skipped: %s (%s)", entry.id, decision.reason)
continue
# Use a custom webhook for Hoyolab feeds.
if is_c3kay_feed(entry.feed.url):
entry_link: str | None = entry.link
if entry_link:
post_id: str | None = extract_post_id_from_hoyolab_url(entry_link)
if post_id:
post_data: dict[str, Any] | None = fetch_hoyolab_post(post_id)
if post_data:
webhook = create_hoyolab_webhook(webhook_url, entry, post_data)
execute_webhook(webhook, entry, reader=effective_reader)
return
logger.warning(
"Failed to create Hoyolab webhook for feed %s, falling back to regular processing",
entry.feed.url,
)
else:
logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
webhook, _delivery_mode = create_webhook_for_entry(
webhook_url,
entry,
effective_reader,
use_default_message_on_empty=True,
)
# Send the entry to Discord because the combined blacklist/whitelist decision allowed it.
execute_webhook(webhook, entry, reader=effective_reader)
@ -741,14 +1252,20 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
break
def execute_webhook(webhook: DiscordWebhook, entry: Entry, reader: Reader) -> None:
def execute_webhook(
webhook: DiscordWebhook,
entry: Entry,
reader: Reader,
*,
save_sent_webhook: bool = True,
) -> None:
"""Execute the webhook.
Args:
webhook (DiscordWebhook): The webhook to execute.
entry (Entry): The entry to send to Discord.
reader (Reader): The Reader instance to use for checking feed status.
save_sent_webhook: Whether to save the sent Discord message metadata for future edits.
"""
# If the feed has been paused or deleted, we will not send the entry to Discord.
entry_feed: Feed = entry.feed
@ -762,6 +1279,7 @@ def execute_webhook(webhook: DiscordWebhook, entry: Entry, reader: Reader) -> No
logger.warning("Feed not found in reader, not sending entry to Discord: %s", entry_feed.url)
return
payload: JsonObject = get_webhook_message_payload(webhook)
response: Response = webhook.execute()
logger.debug("Discord webhook response for entry %s: status=%s", entry.id, response.status_code)
if response.status_code not in {200, 204}:
@ -772,6 +1290,10 @@ def execute_webhook(webhook: DiscordWebhook, entry: Entry, reader: Reader) -> No
logger.error(msg)
else:
logger.info("Sent entry to Discord: %s", entry.id)
if save_sent_webhook:
webhook_url: str = get_webhook_url(reader, entry)
if webhook_url:
upsert_sent_webhook_record(reader, entry, webhook_url, webhook, response, payload)
def is_youtube_feed(feed_url: str) -> bool:
@ -868,6 +1390,9 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None:
# This is the webhook that will be used to send the feed to Discord.
reader.set_tag(clean_feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
# Store sent Discord message ids by default so modified feed entries can edit the original webhook message.
reader.set_tag(clean_feed_url, SAVE_SENT_WEBHOOKS_TAG, True) # pyright: ignore[reportArgumentType]
# 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]

View file

@ -28,7 +28,6 @@ import shutil
import subprocess # noqa: S404
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
if TYPE_CHECKING:
from reader import Reader
@ -37,11 +36,9 @@ logger: logging.Logger = logging.getLogger(__name__)
GIT_EXECUTABLE: str = shutil.which("git") or "git"
type TAG_VALUE = (
dict[str, str | int | float | bool | dict[str, Any] | list[Any] | None]
| list[str | int | float | bool | dict[str, Any] | list[Any] | None]
| None
)
type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
type JsonObject = dict[str, JsonValue]
type TagValue = JsonValue
# Tags that are exported per-feed (empty values are omitted).
_FEED_TAGS: tuple[str, ...] = (
@ -164,24 +161,24 @@ def export_state(reader: Reader, backup_path: Path) -> None:
reader: The :class:`reader.Reader` instance to read state from.
backup_path: Destination directory for the exported ``state.json``.
"""
feeds_state: list[dict] = []
feeds_state: list[JsonObject] = []
for feed in reader.get_feeds():
feed_data: dict = {"url": feed.url}
feed_data: JsonObject = {"url": feed.url}
for tag in _FEED_TAGS:
try:
value: TAG_VALUE = reader.get_tag(feed, tag, None)
value: TagValue = reader.get_tag(feed, tag, None)
if value is not None and value != "": # noqa: PLC1901
feed_data[tag] = value
except Exception:
logger.exception("Failed to read tag '%s' for feed '%s' during state export", tag, feed.url)
feeds_state.append(feed_data)
webhooks: list[str | int | float | bool | dict[str, Any] | list[Any] | None] = list(
webhooks: list[JsonValue] = list(
reader.get_tag((), "webhooks", []),
)
# Export global update interval if set
global_update_interval: dict[str, Any] | None = None
global_update_interval: JsonObject | None = None
global_update_config = reader.get_tag((), ".reader.update", None)
if isinstance(global_update_config, dict):
global_update_interval = global_update_config
@ -193,7 +190,7 @@ def export_state(reader: Reader, backup_path: Path) -> None:
if clean_layout in {"desktop", "mobile"}:
global_screenshot_layout = clean_layout
state: dict = {"feeds": feeds_state, "webhooks": webhooks}
state: JsonObject = {"feeds": feeds_state, "webhooks": webhooks}
if global_update_interval is not None:
state["global_update_interval"] = global_update_interval
if global_screenshot_layout is not None:

View file

@ -5,7 +5,7 @@ import json
import logging
import re
from typing import TYPE_CHECKING
from typing import Any
from typing import cast
import requests
from discord_webhook import DiscordEmbed
@ -17,6 +17,9 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger(__name__)
type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
type JsonObject = dict[str, JsonValue]
def is_c3kay_feed(feed_url: str) -> bool:
"""Check if the feed is from c3kay.de.
@ -50,14 +53,23 @@ def extract_post_id_from_hoyolab_url(url: str) -> str | None:
return None
def fetch_hoyolab_post(post_id: str) -> dict[str, Any] | None:
def as_json_object(value: JsonValue) -> JsonObject:
"""Return value as a JSON object when possible.
Returns:
JsonObject: The input dict or an empty dict.
"""
return cast("JsonObject", value) if isinstance(value, dict) else {}
def fetch_hoyolab_post(post_id: str) -> JsonObject | None:
"""Fetch post data from the Hoyolab API.
Args:
post_id: The post ID to fetch.
Returns:
dict[str, Any] | None: The post data if successful, None otherwise.
JsonObject | None: The post data if successful, None otherwise.
"""
if not post_id:
return None
@ -68,9 +80,11 @@ def fetch_hoyolab_post(post_id: str) -> dict[str, Any] | None:
response: requests.Response = requests.get(url, timeout=10)
if response.status_code == http_ok:
data: dict[str, Any] = response.json()
if data.get("retcode") == 0 and "data" in data and "post" in data["data"]:
return data["data"]["post"]
data = cast("JsonObject", response.json())
data_payload: JsonObject = as_json_object(data.get("data"))
post_payload: JsonObject = as_json_object(data_payload.get("post"))
if data.get("retcode") == 0 and post_payload:
return post_payload
logger.warning("Failed to fetch Hoyolab post %s: %s", post_id, response.text)
except (requests.RequestException, ValueError):
@ -79,7 +93,7 @@ def fetch_hoyolab_post(post_id: str) -> dict[str, Any] | None:
return None
def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str, Any]) -> DiscordWebhook: # noqa: C901, PLR0912, PLR0914, PLR0915
def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject) -> DiscordWebhook: # noqa: C901, PLR0912, PLR0914, PLR0915
"""Create a webhook with data from the Hoyolab API.
Args:
@ -94,22 +108,23 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
webhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True)
# Extract relevant data from the post
post: dict[str, Any] = post_data.get("post", {})
subject: str = post.get("subject", "")
content: str = post.get("content", "{}")
post: JsonObject = as_json_object(post_data.get("post"))
subject: str = str(post.get("subject", ""))
content: str = str(post.get("content", "{}"))
logger.debug("Post subject: %s", subject)
logger.debug("Post content: %s", content)
content_data: dict[str, str] = {}
content_data: JsonObject = {}
with contextlib.suppress(json.JSONDecodeError, ValueError):
content_data = json.loads(content)
loaded_content = cast("JsonValue", json.loads(content))
content_data = as_json_object(loaded_content)
logger.debug("Content data: %s", content_data)
description: str = content_data.get("describe", "")
description: str = str(content_data.get("describe", ""))
if not description:
description = post.get("desc", "")
description = str(post.get("desc", ""))
# Create the embed
discord_embed = DiscordEmbed()
@ -119,7 +134,12 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
discord_embed.set_url(entry_link)
# Get post.image_list
image_list: list[dict[str, Any]] = post_data.get("image_list", [])
image_list_value: JsonValue = post_data.get("image_list", [])
image_list: list[JsonObject] = (
[cast("JsonObject", item) for item in image_list_value if isinstance(item, dict)]
if isinstance(image_list_value, list)
else []
)
if image_list:
image_url: str = str(image_list[0].get("url", ""))
image_height: int = int(image_list[0].get("height", 1080))
@ -128,7 +148,7 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
logger.debug("Image URL: %s, Height: %s, Width: %s", image_url, image_height, image_width)
discord_embed.set_image(url=image_url, height=image_height, width=image_width)
video: dict[str, str | int | bool] = post_data.get("video", {})
video: JsonObject = as_json_object(post_data.get("video"))
if video and video.get("url"):
video_url: str = str(video.get("url", ""))
logger.debug("Video URL: %s", video_url)
@ -140,20 +160,20 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
filename=f"{entry.id}.mp4",
)
game = post_data.get("game", {})
game: JsonObject = as_json_object(post_data.get("game"))
if game and game.get("color"):
game_color = str(game.get("color", ""))
discord_embed.set_color(game_color.removeprefix("#"))
user: dict[str, str | int | bool] = post_data.get("user", {})
user: JsonObject = as_json_object(post_data.get("user"))
author_name: str = str(user.get("nickname", ""))
avatar_url: str = str(user.get("avatar_url", ""))
if author_name:
webhook.avatar_url = avatar_url
webhook.username = author_name
classification = post_data.get("classification", {})
classification: JsonObject = as_json_object(post_data.get("classification"))
if classification and classification.get("name"):
footer = str(classification.get("name", ""))
@ -162,13 +182,19 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
webhook.add_embed(discord_embed)
# Only show Youtube URL if available
structured_content: str = post.get("structured_content", "")
structured_content: str = str(post.get("structured_content", ""))
if structured_content: # noqa: PLR1702
try:
structured_content_data: list[dict[str, Any]] = json.loads(structured_content)
loaded_structured_content = cast("JsonValue", json.loads(structured_content))
structured_content_data: list[JsonObject] = (
[cast("JsonObject", item) for item in loaded_structured_content if isinstance(item, dict)]
if isinstance(loaded_structured_content, list)
else []
)
for item in structured_content_data:
if item.get("insert") and isinstance(item["insert"], dict):
video_url: str = str(item["insert"].get("video", ""))
insert: JsonObject = as_json_object(item.get("insert"))
if insert:
video_url: str = str(insert.get("video", ""))
if video_url:
video_id_match: re.Match[str] | None = re.search(r"embed/([a-zA-Z0-9_-]+)", video_url)
if video_id_match:
@ -180,15 +206,15 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: dict[str,
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Error parsing structured content: %s", e)
event_start_date: str = post.get("event_start_date", "")
event_start_date: str = str(post.get("event_start_date", ""))
if event_start_date and event_start_date != "0":
discord_embed.add_embed_field(name="Start", value=f"<t:{event_start_date}:R>")
event_end_date: str = post.get("event_end_date", "")
event_end_date: str = str(post.get("event_end_date", ""))
if event_end_date and event_end_date != "0":
discord_embed.add_embed_field(name="End", value=f"<t:{event_end_date}:R>")
created_at: str = post.get("created_at", "")
created_at: str = str(post.get("created_at", ""))
if created_at and created_at != "0":
discord_embed.set_timestamp(timestamp=created_at)

View file

@ -15,7 +15,7 @@ from html import escape
from html import unescape
from typing import TYPE_CHECKING
from typing import Annotated
from typing import Any
from typing import TypedDict
from typing import cast
import httpx
@ -51,12 +51,18 @@ from discord_rss_bot.custom_message import get_embed
from discord_rss_bot.custom_message import get_first_image
from discord_rss_bot.custom_message import replace_tags_in_text_message
from discord_rss_bot.custom_message import save_embed
from discord_rss_bot.feeds import SAVE_SENT_WEBHOOKS_TAG
from discord_rss_bot.feeds import SentWebhookRecord
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_screenshot_layout
from discord_rss_bot.feeds import get_sent_webhook_records
from discord_rss_bot.feeds import send_entry_to_discord
from discord_rss_bot.feeds import send_to_discord
from discord_rss_bot.feeds import update_feed_and_collect_modified_entries
from discord_rss_bot.feeds import update_sent_webhooks_for_modified_entries
from discord_rss_bot.filter.evaluator import FILTER_FIELDS
from discord_rss_bot.filter.evaluator import EntryFilterDecision
from discord_rss_bot.filter.evaluator import FilterMatch
@ -78,7 +84,41 @@ if TYPE_CHECKING:
from reader.types import JSONType
LOGGING_CONFIG: dict[str, Any] = {
class PreviewFieldRow(TypedDict):
label: str
value_html: str
badges: list[dict[str, str]]
class FilterPreviewRow(TypedDict):
entry: Entry
decision: EntryFilterDecision
field_rows: list[PreviewFieldRow]
published_label: str
status_label: str
status_class: str
first_image: str
class FilterPreviewSummary(TypedDict):
total: int
sent: int
skipped: int
blacklist_matches: int
whitelist_matches: int
class FilterPreviewContext(TypedDict):
filter_name: str
filter_label: str
preview_rendered_count: int
preview_rows: list[FilterPreviewRow]
preview_limit: int
preview_summary: FilterPreviewSummary
preview_helper_text: str
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
@ -696,7 +736,7 @@ def build_filter_preview_context(
feed: Feed,
filter_name: str,
form_values: dict[str, str] | None = None,
) -> dict[str, Any]:
) -> FilterPreviewContext:
"""Build preview data for the blacklist and whitelist pages.
Args:
@ -706,7 +746,7 @@ def build_filter_preview_context(
form_values: Optional unsaved values from the current form.
Returns:
dict[str, Any]: Preview context for template rendering.
FilterPreviewContext: Preview context for template rendering.
"""
saved_blacklist_values: dict[str, str] = get_filter_values_from_reader(reader, feed, "blacklist")
saved_whitelist_values: dict[str, str] = get_filter_values_from_reader(reader, feed, "whitelist")
@ -724,7 +764,7 @@ def build_filter_preview_context(
helper_text = "Saved blacklist rules still apply while previewing whitelist changes."
preview_entries: list[Entry] = list(reader.get_entries(feed=feed, limit=FILTER_PREVIEW_LIMIT))
preview_rows: list[dict[str, Any]] = []
preview_rows: list[FilterPreviewRow] = []
preview_decisions: dict[str, EntryFilterDecision] = {}
sent_count = 0
skipped_count = 0
@ -782,7 +822,7 @@ def build_filter_preview_context(
}
def build_preview_field_rows(entry: Entry, decision: EntryFilterDecision) -> list[dict[str, Any]]:
def build_preview_field_rows(entry: Entry, decision: EntryFilterDecision) -> list[PreviewFieldRow]:
"""Build labeled preview fields for the filter UI.
Args:
@ -790,10 +830,10 @@ def build_preview_field_rows(entry: Entry, decision: EntryFilterDecision) -> lis
decision: The final decision for the entry.
Returns:
list[dict[str, Any]]: Labeled field rows for the preview template.
list[PreviewFieldRow]: Labeled field rows for the preview template.
"""
entry_fields: dict[str, str] = get_entry_fields(entry)
field_rows: list[dict[str, Any]] = []
field_rows: list[PreviewFieldRow] = []
for field_name in ("title", "author", "summary", "content"):
badges: list[dict[str, str]] = []
@ -1284,6 +1324,34 @@ async def post_use_screenshot_desktop(
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.post("/set_feed_save_sent_webhooks")
async def post_set_feed_save_sent_webhooks(
feed_url: Annotated[str, Form()],
enabled: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
) -> RedirectResponse:
"""Set whether a feed stores sent Discord webhook message records.
Returns:
RedirectResponse: Redirect to the specified feed page.
Raises:
HTTPException: If Feed does not exists.
"""
clean_feed_url: str = feed_url.strip()
should_save: bool = enabled.strip().lower() in {"1", "true", "yes", "on", "enabled"}
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, SAVE_SENT_WEBHOOKS_TAG, should_save) # pyright: ignore[reportArgumentType]
action: str = "Enable" if should_save else "Disable"
commit_state_change(reader, f"{action} sent webhook storage 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()],
@ -1600,6 +1668,7 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
"webhooks": webhooks,
"current_webhook_url": current_webhook_url,
"current_webhook_name": current_webhook_name,
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
}
return templates.TemplateResponse(request=request, name="feed.html", context=context)
@ -1659,6 +1728,7 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
"webhooks": webhooks,
"current_webhook_url": current_webhook_url,
"current_webhook_name": current_webhook_name,
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
}
return templates.TemplateResponse(request=request, name="feed.html", context=context)
@ -1926,6 +1996,50 @@ async def get_webhooks(
return templates.TemplateResponse(request=request, name="webhooks.html", context=context)
@app.get("/sent_webhooks", response_class=HTMLResponse)
async def get_sent_webhooks(
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
feed_url: str = "",
webhook_url: str = "",
) -> HTMLResponse:
"""View sent Discord webhook messages saved for future edits.
Returns:
sent_webhooks.html HTML
"""
clean_feed_url: str = urllib.parse.unquote(feed_url.strip())
clean_webhook_url: str = urllib.parse.unquote(webhook_url.strip())
records: list[SentWebhookRecord] = get_sent_webhook_records(reader)
if clean_feed_url:
records = [record for record in records if record.get("feed_url") == clean_feed_url]
if clean_webhook_url:
records = [record for record in records if record.get("webhook_url") == clean_webhook_url]
records.sort(
key=lambda record: str(record.get("last_updated_at") or record.get("last_sent_at") or ""),
reverse=True,
)
webhooks: list[dict[str, str]] = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
webhook_names: dict[str, str] = {
hook.get("url", ""): hook.get("name", "") for hook in webhooks if isinstance(hook, dict)
}
feed_titles: dict[str, str] = {feed.url: (feed.title or feed.url) for feed in reader.get_feeds()}
context = {
"request": request,
"records": records,
"total_records": len(records),
"feed_url": clean_feed_url,
"webhook_url": clean_webhook_url,
"webhook_names": webhook_names,
"feed_titles": feed_titles,
}
return templates.TemplateResponse(request=request, name="sent_webhooks.html", context=context)
@app.get("/", response_class=HTMLResponse)
def get_index(
request: Request,
@ -2040,10 +2154,16 @@ async def update_feed(
HTTPException: If the feed is not found.
"""
try:
reader.update_feed(urllib.parse.unquote(feed_url))
clean_feed_url: str = urllib.parse.unquote(feed_url)
modified_entries: list[tuple[str, str]] = update_feed_and_collect_modified_entries(reader, clean_feed_url)
except FeedNotFoundError as e:
raise HTTPException(status_code=404, detail="Feed not found") from e
try:
update_sent_webhooks_for_modified_entries(reader, modified_entries)
except (AssertionError, ReaderError, httpx.HTTPError, OSError, ValueError):
logger.exception("Failed to update saved Discord webhooks for manually updated feed: %s", feed_url)
logger.info("Manually updated feed: %s", feed_url)
return RedirectResponse(url="/feed?feed_url=" + urllib.parse.quote(feed_url), status_code=303)

View file

@ -65,6 +65,14 @@ body {
word-break: break-word;
}
.sent-webhooks__entry,
.sent-webhooks__preview {
min-width: 14rem;
max-width: 28rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.filter-page__sidebar {
height: 100%;
}

View file

@ -214,6 +214,23 @@
{% else %}
<p class="text-muted mb-0">Add a webhook first to attach this feed.</p>
{% endif %}
<div class="mt-3 d-flex flex-wrap align-items-center gap-2">
<span class="badge {{ 'bg-success' if save_sent_webhooks else 'bg-secondary' }}">
Sent webhook tracking:
{{ 'Enabled' if save_sent_webhooks else 'Disabled' }}
</span>
<form action="/set_feed_save_sent_webhooks" method="post" class="d-inline">
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
<input type="hidden"
name="enabled"
value="{{ 'false' if save_sent_webhooks else 'true' }}" />
<button class="btn btn-outline-light btn-sm" type="submit">
{{ 'Disable saved webhook updates' if save_sent_webhooks else 'Enable saved webhook updates' }}
</button>
</form>
<a class="btn btn-outline-info btn-sm"
href="/sent_webhooks?feed_url={{ feed.url|encode_url }}">View sent webhooks</a>
</div>
</section>
<section class="mt-4 pt-3 border-top border-secondary-subtle">
<h3 class="h6 text-uppercase text-muted mb-3">Feed Information</h3>

View file

@ -22,6 +22,10 @@
<a class="nav-link" href="/webhooks">Webhooks</a>
</li>
<li class="nav-item nav-link d-none d-md-block">|</li>
<li class="nav-item">
<a class="nav-link" href="/sent_webhooks">Sent webhooks</a>
</li>
<li class="nav-item nav-link d-none d-md-block">|</li>
<li class="nav-item">
<a class="nav-link" href="/settings">Settings</a>
</li>

View file

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}
Sent Webhooks | discord-rss-bot
{% endblock title %}
{% block description %}
Review Discord webhook messages saved for future RSS entry edits.
{% endblock description %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h3 mb-1">Sent webhooks</h2>
<p class="text-muted mb-0">
{{ total_records }} saved message{{ '' if total_records == 1 else 's' }}
{% if feed_url %}for {{ feed_titles.get(feed_url, feed_url) }}{% endif %}
{% if webhook_url %}via {{ webhook_names.get(webhook_url) or 'selected webhook' }}{% endif %}
</p>
</div>
<a class="btn btn-outline-light btn-sm" href="/webhooks">All webhooks</a>
</div>
{% if records %}
<div class="table-responsive">
<table class="table table-dark table-striped align-middle">
<thead>
<tr>
<th scope="col">Entry</th>
<th scope="col">Webhook</th>
<th scope="col">Discord response</th>
<th scope="col">Mode</th>
<th scope="col">Updated</th>
<th scope="col">Preview</th>
</tr>
</thead>
<tbody>
{% for record in records %}
<tr>
<td class="sent-webhooks__entry">
<a class="text-muted"
href="/feed?feed_url={{ record.feed_url|encode_url }}">
{{ feed_titles.get(record.feed_url, record.feed_title or record.feed_url) }}
</a>
<div>
{% if record.entry_link %}
<a class="text-light" href="{{ record.entry_link }}">{{ record.entry_title or record.entry_id }}</a>
{% else %}
<span class="text-light">{{ record.entry_title or record.entry_id }}</span>
{% endif %}
</div>
<code class="text-muted">{{ record.entry_id }}</code>
</td>
<td>
<span class="text-muted">{{ webhook_names.get(record.webhook_url) or 'Stored webhook' }}</span>
</td>
<td>
<div class="mb-1">
<span class="badge bg-secondary">HTTP {{ record.last_status_code or 'unknown' }}</span>
</div>
<div class="small">
Message:
<code>{{ record.message_id }}</code>
</div>
{% if record.discord_response %}
<details class="mt-2">
<summary class="text-muted small">Response JSON</summary>
<pre class="mb-0 mt-1 feed-page__pre">{{ record.discord_response|tojson(indent=2) }}</pre>
</details>
{% elif record.response_text %}
<pre class="mb-0 mt-2 feed-page__pre">{{ record.response_text }}</pre>
{% else %}
<div class="text-muted small mt-1">No saved response body</div>
{% endif %}
{% if record.last_error %}<div class="text-warning small">{{ record.last_error }}</div>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ record.delivery_mode or 'unknown' }}</span>
{% if record.update_count %}
<span class="badge bg-info">{{ record.update_count }} edit{{ '' if record.update_count == 1 else 's' }}</span>
{% endif %}
</td>
<td class="text-muted small">{{ record.last_updated_at or record.last_sent_at or 'Never' }}</td>
<td class="sent-webhooks__preview">
{% if record.payload and record.payload.content %}
<pre class="mb-0 feed-page__pre">{{ record.payload.content }}</pre>
{% elif record.payload and record.payload.embeds %}
<span class="text-muted">{{ record.payload.embeds|length }} embed{{ '' if record.payload.embeds|length == 1 else 's' }}</span>
{% else %}
<span class="text-muted">No text payload</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info" role="alert">No sent webhooks have been saved yet.</div>
{% endif %}
{% endblock content %}