1909 lines
68 KiB
Python
1909 lines
68 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import pprint
|
|
import re
|
|
import time
|
|
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 urljoin
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
import tldextract
|
|
from fastapi import HTTPException
|
|
from markdownify import markdownify
|
|
from playwright.sync_api import Browser
|
|
from playwright.sync_api import Page
|
|
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
from playwright.sync_api import sync_playwright
|
|
from reader import Entry
|
|
from reader import EntryNotFoundError
|
|
from reader import Feed
|
|
from reader import FeedExistsError
|
|
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
|
|
from discord_rss_bot.custom_message import get_image_urls
|
|
from discord_rss_bot.custom_message import replace_tags_in_embed
|
|
from discord_rss_bot.custom_message import replace_tags_in_text_message
|
|
from discord_rss_bot.filter.evaluator import get_entry_filter_decision_from_reader
|
|
from discord_rss_bot.hoyolab_api import create_hoyolab_webhook
|
|
from discord_rss_bot.hoyolab_api import extract_post_id_from_hoyolab_url
|
|
from discord_rss_bot.hoyolab_api import fetch_hoyolab_post
|
|
from discord_rss_bot.hoyolab_api import is_c3kay_feed
|
|
from discord_rss_bot.is_url_valid import is_url_valid
|
|
from discord_rss_bot.settings import default_custom_embed
|
|
from discord_rss_bot.settings import default_custom_message
|
|
from discord_rss_bot.settings import get_reader
|
|
from discord_rss_bot.webhook import DiscordEmbed
|
|
from discord_rss_bot.webhook import DiscordWebhook
|
|
from discord_rss_bot.webhook import WebhookFile
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
from reader._types import EntryData
|
|
|
|
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
|
|
MAX_MEDIA_GALLERY_ITEMS: int = 10
|
|
MESSAGE_FLAG_IS_COMPONENTS_V2: int = 1 << 15
|
|
TTVDROPS_HOST: str = "ttvdrops.lovinator.space"
|
|
TTVDROPS_BASE_URL: str = f"https://{TTVDROPS_HOST}"
|
|
SENT_WEBHOOKS_TAG: str = "sent_webhooks"
|
|
SAVE_SENT_WEBHOOKS_TAG: str = "save_sent_webhooks"
|
|
MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = (
|
|
"allowed_mentions",
|
|
"applied_tags",
|
|
"attachments",
|
|
"avatar_url",
|
|
"components",
|
|
"content",
|
|
"embeds",
|
|
"flags",
|
|
"poll",
|
|
"thread_name",
|
|
"tts",
|
|
"username",
|
|
)
|
|
|
|
|
|
def extract_domain(url: str) -> str: # noqa: PLR0911
|
|
"""Extract the domain name from a URL.
|
|
|
|
Args:
|
|
url: The URL to extract the domain from.
|
|
|
|
Returns:
|
|
str: The domain name, formatted for display.
|
|
"""
|
|
# Check for empty URL first
|
|
if not url:
|
|
return "Other"
|
|
|
|
try:
|
|
# Special handling for YouTube feeds
|
|
if "youtube.com/feeds/videos.xml" in url:
|
|
return "YouTube"
|
|
|
|
# Special handling for Reddit feeds
|
|
if "reddit.com" in url and ".rss" in url:
|
|
return "Reddit"
|
|
|
|
# Parse the URL and extract the domain
|
|
parsed_url: ParseResult = urlparse(url)
|
|
domain: str = parsed_url.netloc
|
|
|
|
# If we couldn't extract a domain, return "Other"
|
|
if not domain:
|
|
return "Other"
|
|
|
|
# Remove www. prefix if present
|
|
domain = re.sub(r"^www\.", "", domain)
|
|
|
|
# Special handling for common domains
|
|
domain_mapping: dict[str, str] = {"github.com": "GitHub"}
|
|
|
|
if domain in domain_mapping:
|
|
return domain_mapping[domain]
|
|
|
|
# Use tldextract to get the domain (SLD)
|
|
ext = tldextract.extract(url)
|
|
if ext.domain:
|
|
return ext.domain.capitalize()
|
|
return domain.capitalize()
|
|
except (ValueError, AttributeError, TypeError) as e:
|
|
logger.warning("Error extracting domain from %s: %s", url, e)
|
|
return "Other"
|
|
|
|
|
|
def send_entry_to_discord(entry: Entry, reader: Reader) -> str | None:
|
|
"""Send a single entry to Discord.
|
|
|
|
Args:
|
|
entry: The entry to send to Discord.
|
|
reader: The reader to use.
|
|
|
|
Returns:
|
|
str | None: The error message if there was an error, otherwise None.
|
|
"""
|
|
# Get the webhook URL for the entry.
|
|
webhook_url: str = str(reader.get_tag(entry.feed_url, "webhook", ""))
|
|
if not webhook_url:
|
|
return "No webhook URL found."
|
|
|
|
# If https://discord.com/quests/<quest_id> is in the URL, send a separate message with the URL.
|
|
send_discord_quest_notification(entry, webhook_url, reader=reader)
|
|
|
|
delivery_mode: DeliveryMode = get_entry_delivery_mode(reader, entry)
|
|
logger.info(
|
|
"Manual send entry %s from %s using delivery_mode=%s",
|
|
entry.id,
|
|
entry.feed.url,
|
|
delivery_mode,
|
|
)
|
|
|
|
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
|
|
|
|
|
|
def get_entry_delivery_mode(reader: Reader, entry: Entry) -> DeliveryMode:
|
|
"""Resolve the effective delivery mode for an entry.
|
|
|
|
Priority order:
|
|
1. YouTube feeds are forced to text mode.
|
|
2. New `delivery_mode` tag when valid.
|
|
3. Legacy `should_send_embed` flag for backwards compatibility.
|
|
|
|
Returns:
|
|
DeliveryMode: The effective delivery mode for this entry.
|
|
"""
|
|
if is_youtube_feed(entry.feed.url):
|
|
return "text"
|
|
|
|
try:
|
|
delivery_mode_raw: str = str(reader.get_tag(entry.feed, "delivery_mode", "")).strip().lower()
|
|
except ReaderError:
|
|
logger.exception("Error getting delivery_mode tag for feed: %s", entry.feed.url)
|
|
delivery_mode_raw = ""
|
|
|
|
if delivery_mode_raw in {"embed", "text", "screenshot"}:
|
|
return cast("DeliveryMode", delivery_mode_raw)
|
|
|
|
try:
|
|
should_send_embed = bool(reader.get_tag(entry.feed, "should_send_embed", True))
|
|
except ReaderError:
|
|
logger.exception("Error getting should_send_embed tag for feed: %s", entry.feed.url)
|
|
should_send_embed = True
|
|
|
|
return "embed" if should_send_embed else "text"
|
|
|
|
|
|
def get_feed_delivery_mode(reader: Reader, feed: Feed) -> DeliveryMode:
|
|
"""Resolve the effective delivery mode for a feed.
|
|
|
|
This mirrors `get_entry_delivery_mode` and is used by the web UI.
|
|
|
|
Returns:
|
|
DeliveryMode: The effective delivery mode for this feed.
|
|
"""
|
|
if is_youtube_feed(feed.url):
|
|
return "text"
|
|
|
|
try:
|
|
delivery_mode_raw: str = str(reader.get_tag(feed, "delivery_mode", "")).strip().lower()
|
|
except ReaderError:
|
|
logger.exception("Error getting delivery_mode tag for feed: %s", feed.url)
|
|
delivery_mode_raw = ""
|
|
|
|
if delivery_mode_raw in {"embed", "text", "screenshot"}:
|
|
return cast("DeliveryMode", delivery_mode_raw)
|
|
|
|
try:
|
|
should_send_embed = bool(reader.get_tag(feed, "should_send_embed", True))
|
|
except ReaderError:
|
|
logger.exception("Error getting should_send_embed tag for feed: %s", feed.url)
|
|
should_send_embed = True
|
|
|
|
return "embed" if should_send_embed else "text"
|
|
|
|
|
|
def get_screenshot_layout(reader: Reader, feed: Feed) -> ScreenshotLayout:
|
|
"""Resolve the screenshot layout for a feed.
|
|
|
|
Returns:
|
|
ScreenshotLayout: The screenshot layout (`desktop` or `mobile`).
|
|
"""
|
|
try:
|
|
screenshot_layout_raw: str = str(reader.get_tag(feed, "screenshot_layout", "desktop")).strip().lower()
|
|
except ReaderError:
|
|
logger.exception("Error getting screenshot_layout tag for feed: %s", feed.url)
|
|
screenshot_layout_raw = "desktop"
|
|
|
|
if screenshot_layout_raw == "mobile":
|
|
return "mobile"
|
|
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_request_payload(webhook: DiscordWebhook) -> JsonObject:
|
|
"""Return the Discord message payload sent to Discord.
|
|
|
|
Runtime fields on the webhook object are intentionally excluded. Unlike
|
|
`get_webhook_message_payload`, this does not add empty defaults because
|
|
Components V2 messages reject otherwise-empty `content` and `embeds` fields.
|
|
|
|
Returns:
|
|
JsonObject: Discord request payload.
|
|
"""
|
|
raw_payload = cast("JsonValue", webhook.json)
|
|
if not isinstance(raw_payload, dict):
|
|
return {}
|
|
|
|
payload: JsonObject = {}
|
|
webhook_payload = cast("JsonObject", raw_payload)
|
|
for key in MESSAGE_PAYLOAD_KEYS:
|
|
if key in webhook_payload:
|
|
payload[key] = webhook_payload[key]
|
|
|
|
return cast("JsonObject", json.loads(json.dumps(payload, default=str)))
|
|
|
|
|
|
def get_webhook_message_payload(webhook: DiscordWebhook) -> JsonObject:
|
|
"""Return the normalized Discord message payload used to compare saved messages.
|
|
|
|
Empty `content`, `embeds`, and `attachments` are kept here so message edits can clear stale content when a feed
|
|
changes delivery mode. Use `get_webhook_request_payload` for the payload sent to Discord.
|
|
|
|
Returns:
|
|
JsonObject: Normalized Discord message payload.
|
|
"""
|
|
payload: JsonObject = get_webhook_request_payload(webhook)
|
|
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_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)
|
|
|
|
if json_value_to_int(edit_payload.get("flags")) & MESSAGE_FLAG_IS_COMPONENTS_V2:
|
|
edit_payload.pop("content", None)
|
|
edit_payload.pop("embeds", None)
|
|
edit_payload.pop("poll", None)
|
|
if edit_payload.get("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.
|
|
|
|
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
|
|
|
|
raw_webhook_id = getattr(webhook, "id", None)
|
|
webhook_id: str | None = raw_webhook_id if isinstance(raw_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 payload_has_components(payload: JsonObject) -> bool:
|
|
"""Return whether a Discord payload includes message components."""
|
|
components: JsonValue = payload.get("components")
|
|
return isinstance(components, list) and bool(components)
|
|
|
|
|
|
def get_webhook_query_params(
|
|
webhook_url: str,
|
|
payload: JsonObject,
|
|
*,
|
|
webhook: DiscordWebhook | None = None,
|
|
wait: bool = True,
|
|
) -> tuple[str, dict[str, str]]:
|
|
"""Return a clean webhook URL and query params for a Discord webhook request."""
|
|
clean_webhook_url, thread_id = split_webhook_url_for_message_endpoint(webhook_url)
|
|
webhook_thread_id = getattr(webhook, "thread_id", None) if webhook is not None else None
|
|
if isinstance(webhook_thread_id, str) and webhook_thread_id.strip():
|
|
thread_id = webhook_thread_id.strip()
|
|
|
|
params: dict[str, str] = {}
|
|
if wait:
|
|
params["wait"] = "true"
|
|
if thread_id:
|
|
params["thread_id"] = thread_id
|
|
if payload_has_components(payload):
|
|
params["with_components"] = "true"
|
|
|
|
return clean_webhook_url, params
|
|
|
|
|
|
def get_webhook_files(webhook: DiscordWebhook) -> list[WebhookFile]: # noqa: C901
|
|
"""Return files attached to a webhook object in a normalized shape."""
|
|
raw_files = getattr(webhook, "files", None)
|
|
files: list[WebhookFile] = []
|
|
|
|
if isinstance(raw_files, dict):
|
|
for filename, content in raw_files.items():
|
|
if isinstance(filename, str) and isinstance(content, bytes):
|
|
files.append(WebhookFile(filename=filename, content=content))
|
|
return files
|
|
|
|
if not isinstance(raw_files, list | tuple):
|
|
return []
|
|
|
|
for index, file_value in enumerate(raw_files):
|
|
if isinstance(file_value, WebhookFile):
|
|
files.append(file_value)
|
|
continue
|
|
|
|
if not isinstance(file_value, tuple) or len(file_value) < 2: # noqa: PLR2004
|
|
continue
|
|
|
|
first, second = file_value[0], file_value[1]
|
|
if isinstance(first, str) and isinstance(second, bytes):
|
|
files.append(WebhookFile(filename=first, content=second))
|
|
continue
|
|
|
|
if isinstance(second, tuple) and len(second) >= 2: # noqa: PLR2004
|
|
nested_file = cast("tuple[object, ...]", second)
|
|
nested_filename, nested_content = nested_file[0], nested_file[1]
|
|
if isinstance(nested_filename, str) and isinstance(nested_content, bytes):
|
|
files.append(WebhookFile(filename=nested_filename, content=nested_content))
|
|
continue
|
|
|
|
if isinstance(second, bytes):
|
|
files.append(WebhookFile(filename=f"file-{index}", content=second))
|
|
|
|
return files
|
|
|
|
|
|
def get_retry_after_seconds(response: httpx.Response) -> float | None:
|
|
"""Return Discord's retry delay for a rate-limited response when available."""
|
|
response_json: JsonObject = get_response_json(response)
|
|
retry_after: JsonValue = response_json.get("retry_after")
|
|
if isinstance(retry_after, int | float | str):
|
|
with suppress(TypeError, ValueError):
|
|
return float(retry_after)
|
|
|
|
retry_after_header: str | None = response.headers.get("retry-after")
|
|
if retry_after_header:
|
|
with suppress(TypeError, ValueError):
|
|
return float(retry_after_header)
|
|
|
|
return None
|
|
|
|
|
|
def request_discord_webhook(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
payload: JsonObject,
|
|
params: dict[str, str],
|
|
files: list[WebhookFile] | None,
|
|
timeout: float,
|
|
rate_limit_retry: bool,
|
|
) -> httpx.Response:
|
|
"""Send a Discord webhook request with optional multipart files.
|
|
|
|
Returns:
|
|
Discord API response.
|
|
"""
|
|
request_kwargs: dict[str, Any] = {"params": params, "timeout": timeout}
|
|
if files:
|
|
request_kwargs["data"] = {"payload_json": json.dumps(payload, default=str)}
|
|
request_kwargs["files"] = [
|
|
(f"files[{index}]", (file.filename, file.content)) for index, file in enumerate(files)
|
|
]
|
|
else:
|
|
request_kwargs["json"] = payload
|
|
|
|
response: httpx.Response = httpx.request(method, url, **request_kwargs)
|
|
if not rate_limit_retry or response.status_code != 429: # noqa: PLR2004
|
|
return response
|
|
|
|
retry_after: float | None = get_retry_after_seconds(response)
|
|
if retry_after is None:
|
|
return response
|
|
|
|
time.sleep(max(0.0, retry_after))
|
|
return httpx.request(method, url, **request_kwargs)
|
|
|
|
|
|
def send_webhook_message(webhook: DiscordWebhook, payload: JsonObject) -> httpx.Response:
|
|
"""Execute a Discord webhook message create request using httpx.
|
|
|
|
Returns:
|
|
Discord API response.
|
|
"""
|
|
clean_webhook_url, params = get_webhook_query_params(webhook.url, payload, webhook=webhook, wait=True)
|
|
return request_discord_webhook(
|
|
"POST",
|
|
clean_webhook_url,
|
|
payload=payload,
|
|
params=params,
|
|
files=get_webhook_files(webhook),
|
|
timeout=cast("int | float", getattr(webhook, "timeout", None) or 30.0),
|
|
rate_limit_retry=bool(getattr(webhook, "rate_limit_retry", False)),
|
|
)
|
|
|
|
|
|
def edit_sent_webhook_message(
|
|
webhook_url: str,
|
|
message_id: str,
|
|
webhook: DiscordWebhook,
|
|
payload: JsonObject,
|
|
) -> httpx.Response:
|
|
"""Edit an already-sent Discord webhook message.
|
|
|
|
Returns:
|
|
httpx.Response: Discord API response.
|
|
"""
|
|
clean_webhook_url, params = get_webhook_query_params(webhook_url, payload, webhook=webhook, wait=True)
|
|
return request_discord_webhook(
|
|
"PATCH",
|
|
f"{clean_webhook_url}/messages/{message_id}",
|
|
payload=payload,
|
|
params=params,
|
|
files=get_webhook_files(webhook),
|
|
timeout=cast("int | float", getattr(webhook, "timeout", None) or 30.0),
|
|
rate_limit_retry=bool(getattr(webhook, "rate_limit_retry", False)),
|
|
)
|
|
|
|
|
|
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
|
|
|
|
previous_payload: JsonObject = json_object_or_empty(record.get("payload"))
|
|
webhook, delivery_mode = create_webhook_for_entry(
|
|
webhook_url_value,
|
|
entry,
|
|
reader,
|
|
use_default_message_on_empty=True,
|
|
)
|
|
payload: JsonObject = preserve_previous_embed_media(
|
|
get_webhook_message_payload(webhook),
|
|
previous_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
|
|
if previous_payload and payload_hash == hash_webhook_payload(previous_payload):
|
|
return (
|
|
{
|
|
**record,
|
|
"payload": payload,
|
|
"payload_hash": payload_hash,
|
|
"delivery_mode": delivery_mode,
|
|
},
|
|
True,
|
|
False,
|
|
)
|
|
|
|
now: str = datetime.datetime.now(tz=datetime.UTC).isoformat()
|
|
try:
|
|
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 (
|
|
{
|
|
**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,
|
|
reader: Reader,
|
|
*,
|
|
use_default_message_on_empty: bool,
|
|
) -> DiscordWebhook:
|
|
"""Create a text webhook using the configured custom message for a feed.
|
|
|
|
Returns:
|
|
DiscordWebhook: Configured webhook that sends a text message.
|
|
"""
|
|
webhook_message: str = ""
|
|
|
|
if get_custom_message(reader, entry.feed) != "": # noqa: PLC1901
|
|
webhook_message = replace_tags_in_text_message(entry=entry, reader=reader)
|
|
|
|
if not webhook_message and use_default_message_on_empty:
|
|
webhook_message = str(default_custom_message)
|
|
|
|
if not webhook_message:
|
|
webhook_message = "No message found."
|
|
|
|
webhook_message = truncate_webhook_message(webhook_message)
|
|
return DiscordWebhook(url=webhook_url, content=webhook_message, rate_limit_retry=True)
|
|
|
|
|
|
def create_screenshot_webhook(webhook_url: str, entry: Entry, reader: Reader) -> DiscordWebhook:
|
|
"""Create a webhook that uploads a full-page screenshot of the entry URL.
|
|
|
|
Returns:
|
|
DiscordWebhook: Configured webhook with screenshot upload, or text fallback on failure.
|
|
"""
|
|
entry_link: str = str(entry.link or "").strip()
|
|
webhook_content: str | None = f"<{entry_link}>" if entry_link else None
|
|
webhook = DiscordWebhook(url=webhook_url, content=webhook_content, rate_limit_retry=True)
|
|
|
|
if not entry_link:
|
|
logger.warning("Entry %s has no link. Falling back to text message for screenshot mode.", entry.id)
|
|
return create_text_webhook(webhook_url, entry, reader=reader, use_default_message_on_empty=True)
|
|
|
|
screenshot_layout: ScreenshotLayout = get_screenshot_layout(reader, entry.feed)
|
|
logger.info(
|
|
"Attempting screenshot capture for entry %s with layout=%s: %s",
|
|
entry.id,
|
|
screenshot_layout,
|
|
entry_link,
|
|
)
|
|
screenshot_bytes: bytes | None = capture_full_page_screenshot(
|
|
entry_link,
|
|
screenshot_layout=screenshot_layout,
|
|
screenshot_type="png",
|
|
)
|
|
screenshot_extension: str = "png"
|
|
|
|
if screenshot_bytes and len(screenshot_bytes) > MAX_DISCORD_UPLOAD_BYTES:
|
|
logger.info(
|
|
"Screenshot for entry %s is too large as PNG (%d bytes). Trying JPEG compression.",
|
|
entry.id,
|
|
len(screenshot_bytes),
|
|
)
|
|
|
|
for quality in (85, 70, 55, 40):
|
|
jpeg_bytes = capture_full_page_screenshot(
|
|
entry_link,
|
|
screenshot_layout=screenshot_layout,
|
|
screenshot_type="jpeg",
|
|
jpeg_quality=quality,
|
|
)
|
|
if jpeg_bytes is None:
|
|
continue
|
|
|
|
logger.info(
|
|
"JPEG quality=%d produced %d bytes for entry %s",
|
|
quality,
|
|
len(jpeg_bytes),
|
|
entry.id,
|
|
)
|
|
screenshot_bytes = jpeg_bytes
|
|
screenshot_extension = "jpg"
|
|
|
|
if len(screenshot_bytes) <= MAX_DISCORD_UPLOAD_BYTES:
|
|
break
|
|
|
|
if screenshot_bytes is None:
|
|
logger.warning(
|
|
"Screenshot capture failed for entry %s (%s). Falling back to text message.",
|
|
entry.id,
|
|
entry_link,
|
|
)
|
|
return create_text_webhook(webhook_url, entry, reader=reader, use_default_message_on_empty=True)
|
|
|
|
if len(screenshot_bytes) > MAX_DISCORD_UPLOAD_BYTES:
|
|
logger.warning(
|
|
"Screenshot for entry %s is still too large after compression (%d bytes). Falling back to text message.",
|
|
entry.id,
|
|
len(screenshot_bytes),
|
|
)
|
|
return create_text_webhook(webhook_url, entry, reader=reader, use_default_message_on_empty=True)
|
|
|
|
filename: str = screenshot_filename_for_entry(entry, extension=screenshot_extension)
|
|
logger.info("Screenshot capture succeeded for entry %s (%d bytes)", entry.id, len(screenshot_bytes))
|
|
webhook.add_file(file=screenshot_bytes, filename=filename)
|
|
return webhook
|
|
|
|
|
|
def screenshot_filename_for_entry(entry: Entry, *, extension: str = "png") -> str:
|
|
"""Build a safe screenshot filename for Discord uploads.
|
|
|
|
Args:
|
|
entry: Entry used to derive a stable filename.
|
|
extension: File extension to use.
|
|
|
|
Returns:
|
|
str: Safe filename ending in the selected extension.
|
|
"""
|
|
base_name: str = str(entry.id or "entry").strip().lower()
|
|
safe_name: str = re.sub(r"[^a-z0-9._-]+", "_", base_name)
|
|
safe_name: str = safe_name.strip("._")
|
|
if not safe_name:
|
|
safe_name = "entry"
|
|
safe_extension: str = re.sub(r"[^a-z0-9]+", "", extension.lower())
|
|
if not safe_extension:
|
|
safe_extension = "png"
|
|
return f"{safe_name[:80]}.{safe_extension}"
|
|
|
|
|
|
def capture_full_page_screenshot(
|
|
url: str,
|
|
*,
|
|
screenshot_layout: ScreenshotLayout = "desktop",
|
|
screenshot_type: ScreenshotFileType = "png",
|
|
jpeg_quality: int = 85,
|
|
) -> bytes | None:
|
|
"""Capture a full-page PNG screenshot for a URL.
|
|
|
|
Returns:
|
|
bytes | None: PNG bytes on success, otherwise None.
|
|
"""
|
|
# Playwright sync API cannot run in an active asyncio loop.
|
|
# FastAPI manual routes run on the event loop, so offload to a worker thread.
|
|
try:
|
|
asyncio.get_running_loop()
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(
|
|
_capture_full_page_screenshot_sync,
|
|
url,
|
|
screenshot_layout=screenshot_layout,
|
|
screenshot_type=screenshot_type,
|
|
jpeg_quality=jpeg_quality,
|
|
)
|
|
return future.result()
|
|
except RuntimeError:
|
|
# No running loop in this thread (e.g. scheduler path).
|
|
return _capture_full_page_screenshot_sync(
|
|
url,
|
|
screenshot_layout=screenshot_layout,
|
|
screenshot_type=screenshot_type,
|
|
jpeg_quality=jpeg_quality,
|
|
)
|
|
|
|
|
|
def _capture_full_page_screenshot_sync(
|
|
url: str,
|
|
*,
|
|
screenshot_layout: ScreenshotLayout = "desktop",
|
|
screenshot_type: ScreenshotFileType = "png",
|
|
jpeg_quality: int = 85,
|
|
) -> bytes | None:
|
|
"""Capture a full-page PNG screenshot for a URL.
|
|
|
|
Returns:
|
|
bytes | None: PNG bytes on success, otherwise None.
|
|
"""
|
|
try:
|
|
with sync_playwright() as playwright:
|
|
browser: Browser = playwright.chromium.launch(
|
|
headless=True,
|
|
args=["--disable-dev-shm-usage", "--no-sandbox"],
|
|
)
|
|
try:
|
|
if screenshot_layout == "mobile":
|
|
page = browser.new_page(
|
|
viewport={"width": 390, "height": 844},
|
|
is_mobile=True,
|
|
has_touch=True,
|
|
device_scale_factor=3,
|
|
color_scheme="dark",
|
|
user_agent=(
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 "
|
|
"Mobile/15E148 Safari/604.1"
|
|
),
|
|
)
|
|
else:
|
|
page = browser.new_page(viewport={"width": 1366, "height": 768}, color_scheme="dark")
|
|
|
|
page = cast("Page", page)
|
|
# `networkidle` can hang on pages with long-polling/analytics;
|
|
# load DOM first and then best-effort wait for network idle.
|
|
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
try:
|
|
page.wait_for_load_state("networkidle", timeout=5000)
|
|
except PlaywrightTimeoutError:
|
|
logger.debug("Timed out waiting for network idle for URL: %s", url)
|
|
|
|
# Scroll through the page in viewport-sized steps to trigger
|
|
# lazy-loaded images and content before taking the screenshot.
|
|
page.evaluate(
|
|
"""
|
|
async () => {
|
|
const viewportHeight = window.innerHeight;
|
|
const totalHeight = document.body.scrollHeight;
|
|
let scrolled = 0;
|
|
while (scrolled < totalHeight) {
|
|
window.scrollBy(0, viewportHeight);
|
|
scrolled += viewportHeight;
|
|
await new Promise(r => setTimeout(r, 200));
|
|
}
|
|
window.scrollTo(0, 0);
|
|
}
|
|
""",
|
|
)
|
|
# Brief pause for any content revealed by scrolling to settle.
|
|
page.wait_for_timeout(500)
|
|
|
|
if screenshot_type == "jpeg":
|
|
clamped_quality: int = max(1, min(100, jpeg_quality))
|
|
return page.screenshot(type="jpeg", quality=clamped_quality, full_page=True)
|
|
|
|
return page.screenshot(type="png", full_page=True)
|
|
finally:
|
|
browser.close()
|
|
except OSError:
|
|
logger.exception("Playwright browser is not installed. Failed to capture screenshot for URL: %s", url)
|
|
except Exception:
|
|
logger.exception("Failed to capture screenshot for URL: %s", url)
|
|
return None
|
|
|
|
|
|
def send_discord_quest_notification(entry: Entry, webhook_url: str, reader: Reader) -> None:
|
|
"""Send a separate message to Discord if the entry is a quest notification."""
|
|
quest_regex: re.Pattern[str] = re.compile(r"https://discord\.com/quests/\d+")
|
|
|
|
def send_notification(quest_url: str) -> None:
|
|
"""Helper function to send quest notification to Discord."""
|
|
logger.info("Sending quest notification to Discord: %s", quest_url)
|
|
webhook = DiscordWebhook(
|
|
url=webhook_url,
|
|
content=quest_url,
|
|
rate_limit_retry=True,
|
|
)
|
|
execute_webhook(webhook, entry, reader=reader, save_sent_webhook=False)
|
|
|
|
# Iterate through the content of the entry
|
|
for content in entry.content:
|
|
if content.type == "text" and content.value:
|
|
match = quest_regex.search(content.value)
|
|
if match:
|
|
send_notification(match.group(0))
|
|
return
|
|
|
|
elif content.type == "text/html" and content.value:
|
|
# Convert HTML to text and check for quest links
|
|
text_value = markdownify(
|
|
html=content.value,
|
|
strip=["img", "table", "td", "tr", "tbody", "thead"],
|
|
escape_misc=False,
|
|
heading_style="ATX",
|
|
)
|
|
match: re.Match[str] | None = quest_regex.search(text_value)
|
|
if match:
|
|
send_notification(match.group(0))
|
|
return
|
|
|
|
logger.info("No quest notification found in entry: %s", entry.id)
|
|
|
|
|
|
def set_description(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None:
|
|
"""Set the description of the embed.
|
|
|
|
Args:
|
|
custom_embed (custom_message.CustomEmbed): The custom embed to get the description from.
|
|
discord_embed (DiscordEmbed): The Discord embed to set the description on.
|
|
"""
|
|
# Its actually 2048, but we will use 2000 to be safe.
|
|
max_description_length: int = 2000
|
|
embed_description: str = custom_embed.description
|
|
embed_description = (
|
|
f"{embed_description[:max_description_length]}..."
|
|
if len(embed_description) > max_description_length
|
|
else embed_description
|
|
)
|
|
discord_embed.set_description(embed_description) if embed_description else None
|
|
|
|
|
|
def set_title(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None:
|
|
"""Set the title of the embed.
|
|
|
|
Args:
|
|
custom_embed: The custom embed to get the title from.
|
|
discord_embed: The Discord embed to set the title on.
|
|
"""
|
|
# Its actually 256, but we will use 200 to be safe.
|
|
max_title_length: int = 200
|
|
embed_title: str = custom_embed.title
|
|
embed_title = f"{embed_title[:max_title_length]}..." if len(embed_title) > max_title_length else embed_title
|
|
discord_embed.set_title(embed_title) if embed_title else None
|
|
|
|
|
|
def add_unique_media_gallery_item(
|
|
media_items: list[JsonObject],
|
|
image_url: str,
|
|
*,
|
|
description: str,
|
|
limit: int = MAX_MEDIA_GALLERY_ITEMS,
|
|
) -> None:
|
|
"""Append a valid media gallery item while preserving order and uniqueness."""
|
|
clean_image_url: str = image_url.strip()
|
|
if (
|
|
len(media_items) >= limit
|
|
or not clean_image_url
|
|
or any(item.get("url") == clean_image_url for item in media_items)
|
|
):
|
|
return
|
|
if not is_url_valid(clean_image_url):
|
|
logger.warning("Invalid media gallery URL: %s", clean_image_url)
|
|
return
|
|
media_items.append({"url": clean_image_url, "description": description[:1024]})
|
|
|
|
|
|
def normalize_ttvdrops_media_url(image_url: str) -> str:
|
|
"""Return an absolute ttvdrops media URL."""
|
|
clean_image_url: str = image_url.strip()
|
|
if not clean_image_url:
|
|
return ""
|
|
return urljoin(TTVDROPS_BASE_URL, clean_image_url)
|
|
|
|
|
|
def get_ttvdrops_campaign_api_url(entry: Entry) -> str:
|
|
"""Return the ttvdrops campaign API URL for an entry when it can be inferred."""
|
|
candidate_urls: tuple[str | None, ...] = (
|
|
entry.link,
|
|
entry.id,
|
|
entry.feed.url,
|
|
)
|
|
|
|
for candidate_url in candidate_urls:
|
|
if not candidate_url:
|
|
continue
|
|
|
|
parsed_url = urlparse(str(candidate_url))
|
|
if parsed_url.netloc.lower() != TTVDROPS_HOST:
|
|
continue
|
|
|
|
if re.fullmatch(r"/twitch/api/v1/campaigns/[^/]+/?", parsed_url.path):
|
|
return parsed_url._replace(query="", fragment="").geturl()
|
|
|
|
campaign_match = re.fullmatch(r"/twitch/campaigns/([^/]+)/?", parsed_url.path)
|
|
if campaign_match:
|
|
campaign_id: str = campaign_match.group(1)
|
|
return parsed_url._replace(
|
|
path=f"/twitch/api/v1/campaigns/{campaign_id}/",
|
|
query="",
|
|
fragment="",
|
|
).geturl()
|
|
|
|
return ""
|
|
|
|
|
|
def get_ttvdrops_reward_description(drop: JsonObject, reward: JsonObject) -> str:
|
|
"""Return alt text for a ttvdrops reward image.
|
|
|
|
Returns:
|
|
Reward alt text suitable for a Media Gallery description.
|
|
"""
|
|
reward_name: str = str(reward.get("name") or drop.get("name") or "Reward")
|
|
required_minutes: int = json_value_to_int(drop.get("required_minutes_watched"))
|
|
required_subs: int = json_value_to_int(drop.get("required_subs"))
|
|
|
|
if required_minutes:
|
|
return f"{required_minutes} minutes watched: {reward_name}"
|
|
if required_subs:
|
|
return f"{required_subs} subscriptions: {reward_name}"
|
|
return reward_name
|
|
|
|
|
|
def extract_ttvdrops_media_gallery_items(value: JsonValue) -> list[JsonObject]: # noqa: C901
|
|
"""Extract benefit/reward media gallery items from a ttvdrops API response.
|
|
|
|
Returns:
|
|
Media Gallery items with absolute URLs and reward descriptions.
|
|
"""
|
|
media_items: list[JsonObject] = []
|
|
|
|
def add_reward_image(drop: JsonObject, reward: JsonObject) -> None:
|
|
image_url = reward.get("image_url")
|
|
if isinstance(image_url, str):
|
|
add_unique_media_gallery_item(
|
|
media_items,
|
|
normalize_ttvdrops_media_url(image_url),
|
|
description=get_ttvdrops_reward_description(drop, reward),
|
|
)
|
|
|
|
def collect_benefit_images(current_value: JsonValue) -> None:
|
|
if isinstance(current_value, dict):
|
|
for key, child_value in current_value.items():
|
|
if key in {"benefits", "rewards"} and isinstance(child_value, list):
|
|
for item in child_value:
|
|
if isinstance(item, dict):
|
|
add_reward_image(cast("JsonObject", current_value), cast("JsonObject", item))
|
|
collect_benefit_images(item)
|
|
continue
|
|
|
|
collect_benefit_images(child_value)
|
|
return
|
|
|
|
if isinstance(current_value, list):
|
|
for item in current_value:
|
|
collect_benefit_images(item)
|
|
|
|
collect_benefit_images(value)
|
|
return media_items
|
|
|
|
|
|
def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]:
|
|
"""Fetch extra campaign media gallery items for ttvdrops entries.
|
|
|
|
Returns:
|
|
Media Gallery items for ttvdrops rewards, or an empty list.
|
|
"""
|
|
api_url: str = get_ttvdrops_campaign_api_url(entry)
|
|
if not api_url:
|
|
return []
|
|
|
|
try:
|
|
response: httpx.Response = httpx.get(api_url, follow_redirects=True, timeout=10.0)
|
|
if response.status_code != 200: # noqa: PLR2004
|
|
logger.warning("Failed to fetch ttvdrops campaign data from %s: %s", api_url, response.text[:500])
|
|
return []
|
|
|
|
response_json = cast("JsonValue", response.json())
|
|
except (httpx.HTTPError, ValueError, TypeError):
|
|
logger.exception("Failed to fetch ttvdrops campaign data from %s", api_url)
|
|
return []
|
|
|
|
return extract_ttvdrops_media_gallery_items(response_json)
|
|
|
|
|
|
def get_entry_media_gallery_items(entry: Entry, custom_embed: CustomEmbed) -> list[JsonObject]:
|
|
"""Return items for a Discord Media Gallery component.
|
|
|
|
Returns:
|
|
Media Gallery items capped to Discord's item limit.
|
|
"""
|
|
media_items: list[JsonObject] = []
|
|
ttvdrops_media_items: list[JsonObject] = fetch_ttvdrops_campaign_media_items(entry)
|
|
if ttvdrops_media_items:
|
|
return ttvdrops_media_items[:MAX_MEDIA_GALLERY_ITEMS]
|
|
|
|
description: str = entry.title or entry.id
|
|
for image_url in get_image_urls(entry.summary, entry.content, limit=MAX_MEDIA_GALLERY_ITEMS):
|
|
add_unique_media_gallery_item(media_items, image_url, description=description)
|
|
|
|
add_unique_media_gallery_item(media_items, custom_embed.image_url, description=description)
|
|
add_unique_media_gallery_item(media_items, custom_embed.thumbnail_url, description=description)
|
|
|
|
return media_items[:MAX_MEDIA_GALLERY_ITEMS]
|
|
|
|
|
|
def truncate_component_text(content: str) -> str:
|
|
"""Trim a Text Display component to a conservative Discord-safe length.
|
|
|
|
Returns:
|
|
Original or truncated component text.
|
|
"""
|
|
max_text_display_length: int = 4000
|
|
if len(content) <= max_text_display_length:
|
|
return content
|
|
return f"{content[: max_text_display_length - 3]}..."
|
|
|
|
|
|
def get_component_text_display_content(custom_embed: CustomEmbed, entry: Entry) -> str:
|
|
"""Build markdown text for a Components V2 Text Display.
|
|
|
|
Returns:
|
|
Markdown content for a Text Display component.
|
|
"""
|
|
parts: list[str] = []
|
|
|
|
if custom_embed.title:
|
|
parts.append(f"# {custom_embed.title}")
|
|
|
|
if custom_embed.author_name and custom_embed.author_url:
|
|
parts.append(f"## [{custom_embed.author_name}]({custom_embed.author_url})")
|
|
elif custom_embed.author_name:
|
|
parts.append(f"## {custom_embed.author_name}")
|
|
elif custom_embed.author_url:
|
|
parts.append(f"<{custom_embed.author_url}>")
|
|
|
|
if custom_embed.description:
|
|
parts.append(custom_embed.description)
|
|
|
|
if custom_embed.footer_text:
|
|
parts.append(f"-# {custom_embed.footer_text}")
|
|
|
|
if not parts:
|
|
fallback_text: str = entry.title or entry.link or entry.id
|
|
if entry.link and fallback_text != entry.link:
|
|
fallback_text = f"[{fallback_text}]({entry.link})"
|
|
parts.append(fallback_text)
|
|
|
|
return truncate_component_text("\n\n".join(parts))
|
|
|
|
|
|
def create_media_gallery_component(media_items: list[JsonObject]) -> JsonObject:
|
|
"""Build a Discord Media Gallery component.
|
|
|
|
Returns:
|
|
Discord Media Gallery component payload.
|
|
"""
|
|
return {
|
|
"type": 12,
|
|
"items": [
|
|
{
|
|
"media": {"url": media_item["url"]},
|
|
"description": media_item["description"],
|
|
}
|
|
for media_item in media_items[:MAX_MEDIA_GALLERY_ITEMS]
|
|
if isinstance(media_item.get("url"), str) and isinstance(media_item.get("description"), str)
|
|
],
|
|
}
|
|
|
|
|
|
def create_components_v2_webhook(
|
|
webhook_url: str,
|
|
entry: Entry,
|
|
custom_embed: CustomEmbed,
|
|
media_items: list[JsonObject],
|
|
) -> DiscordWebhook:
|
|
"""Create a Components V2 webhook with text and a media gallery.
|
|
|
|
Returns:
|
|
Webhook payload configured for Components V2.
|
|
"""
|
|
components: list[JsonValue] = [
|
|
{
|
|
"type": 10,
|
|
"content": get_component_text_display_content(custom_embed, entry),
|
|
},
|
|
create_media_gallery_component(media_items),
|
|
]
|
|
return DiscordWebhook(
|
|
url=webhook_url,
|
|
flags=MESSAGE_FLAG_IS_COMPONENTS_V2,
|
|
components=components,
|
|
rate_limit_retry=True,
|
|
)
|
|
|
|
|
|
def create_embed_webhook( # noqa: C901
|
|
webhook_url: str,
|
|
entry: Entry,
|
|
reader: Reader,
|
|
) -> DiscordWebhook:
|
|
"""Create a webhook with an embed.
|
|
|
|
Args:
|
|
webhook_url (str): The webhook URL.
|
|
entry (Entry): The entry to send to Discord.
|
|
reader (Reader): The Reader instance to use for getting embed data.
|
|
|
|
Returns:
|
|
DiscordWebhook: The webhook with the embed.
|
|
"""
|
|
webhook: DiscordWebhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True)
|
|
feed: Feed = entry.feed
|
|
|
|
# Get the embed data from the database.
|
|
custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader)
|
|
media_gallery_items: list[JsonObject] = get_entry_media_gallery_items(entry, custom_embed)
|
|
if media_gallery_items:
|
|
return create_components_v2_webhook(webhook_url, entry, custom_embed, media_gallery_items)
|
|
|
|
discord_embed: DiscordEmbed = DiscordEmbed()
|
|
|
|
set_description(custom_embed=custom_embed, discord_embed=discord_embed)
|
|
set_title(custom_embed=custom_embed, discord_embed=discord_embed)
|
|
|
|
custom_embed_author_url: str | None = custom_embed.author_url
|
|
if not is_url_valid(custom_embed_author_url):
|
|
custom_embed_author_url = None
|
|
|
|
custom_embed_color: str | None = custom_embed.color or None
|
|
if custom_embed_color and custom_embed_color.startswith("#"):
|
|
custom_embed_color = custom_embed_color[1:]
|
|
discord_embed.set_color(int(custom_embed_color, 16))
|
|
|
|
if custom_embed.author_name and not custom_embed_author_url and not custom_embed.author_icon_url:
|
|
discord_embed.set_author(name=custom_embed.author_name)
|
|
|
|
if custom_embed.author_name and custom_embed_author_url and not custom_embed.author_icon_url:
|
|
discord_embed.set_author(name=custom_embed.author_name, url=custom_embed_author_url)
|
|
|
|
if custom_embed.author_name and not custom_embed_author_url and custom_embed.author_icon_url:
|
|
discord_embed.set_author(name=custom_embed.author_name, icon_url=custom_embed.author_icon_url)
|
|
|
|
if custom_embed.author_name and custom_embed_author_url and custom_embed.author_icon_url:
|
|
discord_embed.set_author(
|
|
name=custom_embed.author_name,
|
|
url=custom_embed_author_url,
|
|
icon_url=custom_embed.author_icon_url,
|
|
)
|
|
|
|
if custom_embed.thumbnail_url:
|
|
discord_embed.set_thumbnail(url=custom_embed.thumbnail_url)
|
|
|
|
if custom_embed.image_url:
|
|
discord_embed.set_image(url=custom_embed.image_url)
|
|
|
|
if custom_embed.footer_text:
|
|
discord_embed.set_footer(text=custom_embed.footer_text)
|
|
|
|
if custom_embed.footer_icon_url and custom_embed.footer_text:
|
|
discord_embed.set_footer(text=custom_embed.footer_text, icon_url=custom_embed.footer_icon_url)
|
|
|
|
if custom_embed.footer_icon_url and not custom_embed.footer_text:
|
|
discord_embed.set_footer(text="-", icon_url=custom_embed.footer_icon_url)
|
|
|
|
webhook.add_embed(discord_embed)
|
|
return webhook
|
|
|
|
|
|
def get_webhook_url(reader: Reader, entry: Entry) -> str:
|
|
"""Get the webhook URL for the entry.
|
|
|
|
Args:
|
|
reader: The reader to use.
|
|
entry: The entry to get the webhook URL for.
|
|
|
|
Returns:
|
|
str: The webhook URL.
|
|
"""
|
|
try:
|
|
webhook_url: str = str(reader.get_tag(entry.feed_url, "webhook", ""))
|
|
except StorageError:
|
|
logger.exception("Storage error getting webhook URL for feed: %s", entry.feed.url)
|
|
return ""
|
|
|
|
if not webhook_url:
|
|
logger.error("No webhook URL found for feed: %s", entry.feed.url)
|
|
return ""
|
|
return webhook_url
|
|
|
|
|
|
def set_entry_as_read(reader: Reader, entry: Entry) -> None:
|
|
"""Set the webhook to read, so we don't send it again.
|
|
|
|
Args:
|
|
reader: The reader to use.
|
|
entry: The entry to set as read.
|
|
"""
|
|
try:
|
|
reader.set_entry_read(entry, True)
|
|
except EntryNotFoundError:
|
|
logger.exception("Error setting entry to read: %s", entry.id)
|
|
except StorageError:
|
|
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:
|
|
"""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.
|
|
|
|
Args:
|
|
reader: If we should use a custom reader instead of the default one.
|
|
feed: The feed to send to Discord.
|
|
do_once: If we should only send one entry. This is used in the test.
|
|
"""
|
|
logger.info("Starting to send entries to Discord.")
|
|
# 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 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)
|
|
for entry in entries:
|
|
set_entry_as_read(effective_reader, entry)
|
|
|
|
if entry.added < datetime.datetime.now(tz=entry.added.tzinfo) - datetime.timedelta(days=1):
|
|
logger.info("Entry is older than 24 hours: %s from %s", entry.id, entry.feed.url)
|
|
continue
|
|
|
|
webhook_url: str = get_webhook_url(effective_reader, entry)
|
|
if not webhook_url:
|
|
logger.info("No webhook URL found for feed: %s", entry.feed.url)
|
|
continue
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
# If we only want to send one entry, we will break the loop. This is used when testing this function.
|
|
if do_once:
|
|
logger.info("Sent one entry to Discord. Breaking the loop.")
|
|
break
|
|
|
|
|
|
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
|
|
if entry_feed.updates_enabled is False:
|
|
logger.warning("Feed is paused, not sending entry to Discord: %s", entry_feed.url)
|
|
return
|
|
|
|
try:
|
|
reader.get_feed(entry_feed.url)
|
|
except FeedNotFoundError:
|
|
logger.warning("Feed not found in reader, not sending entry to Discord: %s", entry_feed.url)
|
|
return
|
|
|
|
request_payload: JsonObject = get_webhook_request_payload(webhook)
|
|
payload: JsonObject = get_webhook_message_payload(webhook)
|
|
response: httpx.Response = send_webhook_message(webhook, request_payload)
|
|
logger.debug("Discord webhook response for entry %s: status=%s", entry.id, response.status_code)
|
|
if response.status_code not in {200, 204}:
|
|
msg: str = f"Error sending entry to Discord: {response.text}\n{pprint.pformat(request_payload)}"
|
|
if entry:
|
|
msg += f"\n{entry}"
|
|
|
|
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:
|
|
"""Check if the feed is a YouTube feed.
|
|
|
|
Args:
|
|
feed_url: The feed URL to check.
|
|
|
|
Returns:
|
|
bool: True if the feed is a YouTube feed, False otherwise.
|
|
"""
|
|
return "youtube.com/feeds/videos.xml" in feed_url
|
|
|
|
|
|
def should_send_embed_check(reader: Reader, entry: Entry) -> bool:
|
|
"""Check if we should send an embed to Discord.
|
|
|
|
Args:
|
|
reader (Reader): The reader to use.
|
|
entry (Entry): The entry to check.
|
|
|
|
Returns:
|
|
bool: True if we should send an embed, False otherwise.
|
|
"""
|
|
return get_entry_delivery_mode(reader, entry) == "embed"
|
|
|
|
|
|
def truncate_webhook_message(webhook_message: str) -> str:
|
|
"""Truncate the webhook message if it is too long.
|
|
|
|
Args:
|
|
webhook_message (str): The webhook message to truncate.
|
|
|
|
Returns:
|
|
str: The truncated webhook message.
|
|
"""
|
|
max_content_length: int = 4000
|
|
if len(webhook_message) > max_content_length:
|
|
half_length = (max_content_length - 3) // 2 # Subtracting 3 for the "..." in the middle
|
|
webhook_message = f"{webhook_message[:half_length]}...{webhook_message[-half_length:]}"
|
|
return webhook_message
|
|
|
|
|
|
def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # noqa: C901, PLR0912
|
|
"""Add a new feed, update it and mark every entry as read.
|
|
|
|
Args:
|
|
reader: The reader to use.
|
|
feed_url: The feed to add.
|
|
webhook_dropdown: The webhook we should send entries to.
|
|
|
|
Raises:
|
|
HTTPException: If webhook_dropdown does not equal a webhook or default_custom_message not found.
|
|
"""
|
|
clean_feed_url: str = feed_url.strip()
|
|
webhook_url: str = ""
|
|
if hooks := reader.get_tag((), "webhooks", []):
|
|
# Get the webhook URL from the dropdown.
|
|
for hook in hooks:
|
|
if not isinstance(hook, dict):
|
|
logger.error("Webhook is not a dict: %s", hook)
|
|
continue
|
|
|
|
if hook["name"] == webhook_dropdown: # pyright: ignore[reportArgumentType]
|
|
webhook_url = hook["url"]
|
|
break
|
|
|
|
if not webhook_url:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
|
|
try:
|
|
reader.add_feed(clean_feed_url)
|
|
except FeedExistsError:
|
|
# Add the webhook to an already added feed if it doesn't have a webhook instead of trying to create a new.
|
|
if not reader.get_tag(clean_feed_url, "webhook", ""):
|
|
reader.set_tag(clean_feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
|
|
except ReaderError as e:
|
|
raise HTTPException(status_code=404, detail=f"Error adding feed: {e}") from e
|
|
|
|
try:
|
|
reader.update_feed(clean_feed_url)
|
|
except ReaderError as e:
|
|
raise HTTPException(status_code=404, detail=f"Error updating feed: {e}") from e
|
|
|
|
# Mark every entry as read, so we don't send all the old entries to Discord.
|
|
entries: Iterable[Entry] = reader.get_entries(feed=clean_feed_url, read=False)
|
|
for entry in entries:
|
|
reader.set_entry_read(entry, True)
|
|
|
|
if not default_custom_message:
|
|
# TODO(TheLovinator): Show this error on the page.
|
|
raise HTTPException(status_code=404, detail="Default custom message couldn't be found.")
|
|
|
|
# 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]
|
|
|
|
global_screenshot_layout: str = str(reader.get_tag((), "screenshot_layout", "desktop")).strip().lower()
|
|
if global_screenshot_layout not in {"desktop", "mobile"}:
|
|
global_screenshot_layout = "desktop"
|
|
reader.set_tag(clean_feed_url, "screenshot_layout", global_screenshot_layout) # pyright: ignore[reportArgumentType]
|
|
|
|
global_delivery_mode: str = str(reader.get_tag((), "delivery_mode", "embed")).strip().lower()
|
|
if global_delivery_mode not in {"embed", "text"}:
|
|
global_delivery_mode = "embed"
|
|
reader.set_tag(clean_feed_url, "delivery_mode", global_delivery_mode) # pyright: ignore[reportArgumentType]
|
|
reader.set_tag(clean_feed_url, "should_send_embed", global_delivery_mode == "embed") # pyright: ignore[reportArgumentType]
|
|
|
|
# Set the default embed tag when creating the feed
|
|
reader.set_tag(clean_feed_url, "embed", json.dumps(default_custom_embed)) # pyright: ignore[reportArgumentType]
|
|
|
|
# Update the full-text search index so our new feed is searchable.
|
|
reader.update_search()
|