From f3989eefa98e57607743dd199759ed781c803e81 Mon Sep 17 00:00:00 2001 From: LostOnTheLine <60684934+LostOnTheLine@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:35:12 -0700 Subject: [PATCH] Add options for custom Username & Avatar Image --- discord_rss_bot/custom_message.py | 92 +++++++++++++++++++++++++++ discord_rss_bot/feeds.py | 44 +++++++++---- discord_rss_bot/git_backup.py | 2 + discord_rss_bot/main.py | 12 ++++ discord_rss_bot/templates/custom.html | 22 +++++++ 5 files changed, 161 insertions(+), 11 deletions(-) diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py index 4a5c819..43b16a8 100644 --- a/discord_rss_bot/custom_message.py +++ b/discord_rss_bot/custom_message.py @@ -25,6 +25,11 @@ logger: logging.Logger = logging.getLogger(__name__) DISCORD_TIMESTAMP_TAG_RE: re.Pattern[str] = re.compile(r"") +# Discord webhook username: nickname rules, max 80 chars; no "clyde"/"discord" substrings. +DISCORD_WEBHOOK_USERNAME_MAX_LENGTH: int = 80 +DISCORD_WEBHOOK_USERNAME_FORBIDDEN_CHARS: frozenset[str] = frozenset("@#:`") +DISCORD_WEBHOOK_USERNAME_FORBIDDEN_SUBSTRINGS: tuple[str, ...] = ("clyde", "discord") + @dataclass(slots=True) class CustomEmbed: @@ -391,6 +396,93 @@ def get_custom_message(reader: Reader, feed: Feed) -> str: return custom_message +def get_message_username(reader: Reader, feed: Feed) -> str: + """Get the stored custom webhook username for a feed. + + Returns: + Stored username (may be empty or invalid for Discord). + """ + try: + return str(reader.get_tag(feed, "message_username", "")) + except ValueError: + return "" + + +def get_message_avatar_url(reader: Reader, feed: Feed) -> str: + """Get the stored custom webhook avatar URL for a feed. + + Returns: + Stored avatar URL (may be empty or invalid for Discord). + """ + try: + return str(reader.get_tag(feed, "message_avatar_url", "")) + except ValueError: + return "" + + +def normalize_message_username(username: str | None) -> str: + """Return a Discord-safe webhook username, or empty string if unusable. + + Blank or invalid values are rejected so Discord uses the webhook default. + + Returns: + Valid username, or empty string when the override should not be sent. + """ + if not username: + return "" + + cleaned: str = username.strip() + if not cleaned: + return "" + if len(cleaned) > DISCORD_WEBHOOK_USERNAME_MAX_LENGTH: + return "" + if any(character in cleaned for character in DISCORD_WEBHOOK_USERNAME_FORBIDDEN_CHARS): + return "" + lowered: str = cleaned.lower() + if any(forbidden in lowered for forbidden in DISCORD_WEBHOOK_USERNAME_FORBIDDEN_SUBSTRINGS): + return "" + return cleaned + + +def normalize_message_avatar_url(avatar_url: str | None) -> str: + """Return a usable webhook avatar URL, or empty string if unusable. + + Blank or invalid values are rejected so Discord uses the webhook default. + + Returns: + Valid http(s) URL, or empty string when the override should not be sent. + """ + if not avatar_url: + return "" + + cleaned: str = avatar_url.strip() + if not cleaned: + return "" + if not cleaned.lower().startswith(("http://", "https://")): + return "" + if not is_url_valid(cleaned): + return "" + return cleaned + + +def get_validated_message_username(reader: Reader, feed: Feed) -> str: + """Get a Discord-safe custom webhook username for a feed, if configured. + + Returns: + Valid username to send, or empty string to use the webhook default. + """ + return normalize_message_username(get_message_username(reader, feed)) + + +def get_validated_message_avatar_url(reader: Reader, feed: Feed) -> str: + """Get a usable custom webhook avatar URL for a feed, if configured. + + Returns: + Valid avatar URL to send, or empty string to use the webhook default. + """ + return normalize_message_avatar_url(get_message_avatar_url(reader, feed)) + + def save_embed(reader: Reader, feed: Feed, embed: CustomEmbed) -> None: """Set embed tag in feed. diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index a75629e..7af4f3c 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -48,6 +48,8 @@ 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 get_validated_message_avatar_url +from discord_rss_bot.custom_message import get_validated_message_username 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 @@ -644,6 +646,10 @@ def get_webhook_message_edit_payload(payload: JsonObject, record: SentWebhookRec if edit_payload.get("attachments") == []: edit_payload.pop("attachments", None) + # Username/avatar can only be set when creating a message, not when editing. + edit_payload.pop("username", None) + edit_payload.pop("avatar_url", None) + return edit_payload @@ -962,6 +968,21 @@ def edit_sent_webhook_message( ) +def apply_feed_webhook_identity(webhook: DiscordWebhook, entry: Entry, reader: Reader) -> DiscordWebhook: + """Apply per-feed custom username and avatar when valid; ignore blank/invalid values. + + Returns: + The same webhook instance with optional identity overrides. + """ + username: str = get_validated_message_username(reader, entry.feed) + avatar_url: str = get_validated_message_avatar_url(reader, entry.feed) + if username: + webhook.username = username + if avatar_url: + webhook.avatar_url = avatar_url + return webhook + + def create_webhook_for_entry( webhook_url: str, entry: Entry, @@ -983,7 +1004,8 @@ def create_webhook_for_entry( if post_id: post_data = fetch_hoyolab_post(post_id) if post_data: - return create_hoyolab_webhook(webhook_url, entry, post_data), delivery_mode + webhook = create_hoyolab_webhook(webhook_url, entry, post_data) + return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode logger.warning( "Failed to create Hoyolab webhook for feed %s, falling back to regular processing", entry.feed.url, @@ -992,18 +1014,18 @@ def create_webhook_for_entry( 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 + webhook = create_embed_webhook(webhook_url, entry, reader=reader) + return apply_feed_webhook_identity(webhook, entry, 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, + webhook = create_screenshot_webhook(webhook_url, entry, reader=reader) + return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode + webhook = create_text_webhook( + webhook_url, + entry, + reader=reader, + use_default_message_on_empty=use_default_message_on_empty, ) + return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode def collect_modified_entries_during_update(reader: Reader, update_callback: UpdateCallback) -> list[tuple[str, str]]: diff --git a/discord_rss_bot/git_backup.py b/discord_rss_bot/git_backup.py index 2784e6f..b7a7f16 100644 --- a/discord_rss_bot/git_backup.py +++ b/discord_rss_bot/git_backup.py @@ -44,6 +44,8 @@ type TagValue = JsonValue _FEED_TAGS: tuple[str, ...] = ( "webhook", "custom_message", + "message_username", + "message_avatar_url", "delivery_mode", "screenshot_layout", "should_send_embed", diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index acc07d9..44b85a0 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -49,6 +49,8 @@ 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_embed from discord_rss_bot.custom_message import get_first_image +from discord_rss_bot.custom_message import get_message_avatar_url +from discord_rss_bot.custom_message import get_message_username 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 FeedUpdateError @@ -1145,11 +1147,15 @@ async def post_set_custom( feed_url: Annotated[str, Form()], reader: Annotated[Reader, Depends(get_reader_dependency)], custom_message: Annotated[str, Form()] = "", + message_username: Annotated[str, Form()] = "", + message_avatar_url: Annotated[str, Form()] = "", ) -> RedirectResponse: """Set the custom message, this is used when sending the message. Args: custom_message: The custom message. + message_username: Optional Discord webhook username override for this feed. + message_avatar_url: Optional Discord webhook avatar URL override for this feed. feed_url: The feed we should set the custom message for. reader: The Reader instance. @@ -1159,6 +1165,10 @@ async def post_set_custom( our_custom_message: JSONType | str = custom_message.strip() our_custom_message = typing.cast("JSONType", our_custom_message) + # Store raw values; blank/invalid values are ignored when building the Discord payload. + reader.set_tag(feed_url, "message_username", typing.cast("JSONType", message_username.strip())) + reader.set_tag(feed_url, "message_avatar_url", typing.cast("JSONType", message_avatar_url.strip())) + clean_feed_url: str = feed_url.strip() feed: Feed = reader.get_feed(urllib.parse.unquote(clean_feed_url)) @@ -1192,6 +1202,8 @@ async def get_custom( "request": request, "feed": feed, "custom_message": get_custom_message(reader, feed), + "message_username": get_message_username(reader, feed), + "message_avatar_url": get_message_avatar_url(reader, feed), } # Get the first entry, this is used to show the user what the custom message will look like. diff --git a/discord_rss_bot/templates/custom.html b/discord_rss_bot/templates/custom.html index 69c5f5f..f2af474 100644 --- a/discord_rss_bot/templates/custom.html +++ b/discord_rss_bot/templates/custom.html @@ -240,6 +240,28 @@ id="custom_message" {% if custom_message %} value="{{- custom_message -}}" {% endif %} /> + +
+
+
+
    +
  • Optional: override the Discord webhook username and avatar for messages from this feed.
  • +
  • Leave blank to use the webhook's default name and avatar.
  • +
  • Invalid values (empty, disallowed characters, or bad avatar URLs) are ignored when sending.
  • +
  • Username rules: 1–80 characters; cannot contain @, #, :, or `; cannot contain clyde or discord.
  • +
  • Avatar must be a full http:// or https:// image URL.
  • +
+
+ + + + +
+