Add options for custom Username & Avatar Image

This commit is contained in:
LostOnTheLine 2026-07-16 22:35:12 -07:00 committed by Joakim Hellsén
commit f3989eefa9
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
5 changed files with 161 additions and 11 deletions

View file

@ -25,6 +25,11 @@ logger: logging.Logger = logging.getLogger(__name__)
DISCORD_TIMESTAMP_TAG_RE: re.Pattern[str] = re.compile(r"<t:\d+(?::[tTdDfFrRsS])?>") DISCORD_TIMESTAMP_TAG_RE: re.Pattern[str] = re.compile(r"<t:\d+(?::[tTdDfFrRsS])?>")
# 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) @dataclass(slots=True)
class CustomEmbed: class CustomEmbed:
@ -391,6 +396,93 @@ def get_custom_message(reader: Reader, feed: Feed) -> str:
return custom_message 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: def save_embed(reader: Reader, feed: Feed, embed: CustomEmbed) -> None:
"""Set embed tag in feed. """Set embed tag in feed.

View file

@ -48,6 +48,8 @@ from requests import RequestException
from discord_rss_bot.custom_message import CustomEmbed 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_custom_message
from discord_rss_bot.custom_message import get_image_urls 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_embed
from discord_rss_bot.custom_message import replace_tags_in_text_message 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.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") == []: if edit_payload.get("attachments") == []:
edit_payload.pop("attachments", None) 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 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( def create_webhook_for_entry(
webhook_url: str, webhook_url: str,
entry: Entry, entry: Entry,
@ -983,7 +1004,8 @@ def create_webhook_for_entry(
if post_id: if post_id:
post_data = fetch_hoyolab_post(post_id) post_data = fetch_hoyolab_post(post_id)
if post_data: 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( logger.warning(
"Failed to create Hoyolab webhook for feed %s, falling back to regular processing", "Failed to create Hoyolab webhook for feed %s, falling back to regular processing",
entry.feed.url, 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) logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
if delivery_mode == "embed": 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": if delivery_mode == "screenshot":
return create_screenshot_webhook(webhook_url, entry, reader=reader), delivery_mode webhook = create_screenshot_webhook(webhook_url, entry, reader=reader)
return ( return apply_feed_webhook_identity(webhook, entry, reader), delivery_mode
create_text_webhook( webhook = create_text_webhook(
webhook_url, webhook_url,
entry, entry,
reader=reader, reader=reader,
use_default_message_on_empty=use_default_message_on_empty, use_default_message_on_empty=use_default_message_on_empty,
),
delivery_mode,
) )
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]]: def collect_modified_entries_during_update(reader: Reader, update_callback: UpdateCallback) -> list[tuple[str, str]]:

View file

@ -44,6 +44,8 @@ type TagValue = JsonValue
_FEED_TAGS: tuple[str, ...] = ( _FEED_TAGS: tuple[str, ...] = (
"webhook", "webhook",
"custom_message", "custom_message",
"message_username",
"message_avatar_url",
"delivery_mode", "delivery_mode",
"screenshot_layout", "screenshot_layout",
"should_send_embed", "should_send_embed",

View file

@ -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_custom_message
from discord_rss_bot.custom_message import get_embed 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_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 replace_tags_in_text_message
from discord_rss_bot.custom_message import save_embed from discord_rss_bot.custom_message import save_embed
from discord_rss_bot.feeds import FeedUpdateError from discord_rss_bot.feeds import FeedUpdateError
@ -1145,11 +1147,15 @@ async def post_set_custom(
feed_url: Annotated[str, Form()], feed_url: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)], reader: Annotated[Reader, Depends(get_reader_dependency)],
custom_message: Annotated[str, Form()] = "", custom_message: Annotated[str, Form()] = "",
message_username: Annotated[str, Form()] = "",
message_avatar_url: Annotated[str, Form()] = "",
) -> RedirectResponse: ) -> RedirectResponse:
"""Set the custom message, this is used when sending the message. """Set the custom message, this is used when sending the message.
Args: Args:
custom_message: The custom message. 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. feed_url: The feed we should set the custom message for.
reader: The Reader instance. reader: The Reader instance.
@ -1159,6 +1165,10 @@ async def post_set_custom(
our_custom_message: JSONType | str = custom_message.strip() our_custom_message: JSONType | str = custom_message.strip()
our_custom_message = typing.cast("JSONType", our_custom_message) 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() clean_feed_url: str = feed_url.strip()
feed: Feed = reader.get_feed(urllib.parse.unquote(clean_feed_url)) feed: Feed = reader.get_feed(urllib.parse.unquote(clean_feed_url))
@ -1192,6 +1202,8 @@ async def get_custom(
"request": request, "request": request,
"feed": feed, "feed": feed,
"custom_message": get_custom_message(reader, 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. # Get the first entry, this is used to show the user what the custom message will look like.

View file

@ -240,6 +240,28 @@
id="custom_message" {% if custom_message %} value="{{- custom_message -}}" {% endif %} /> id="custom_message" {% if custom_message %} value="{{- custom_message -}}" {% endif %} />
</div> </div>
</div> </div>
<!-- Optional Discord webhook username & avatar for this feed -->
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<li>Optional: override the Discord webhook username and avatar for messages from this feed.</li>
<li>Leave blank to use the webhook's default name and avatar.</li>
<li>Invalid values (empty, disallowed characters, or bad avatar URLs) are ignored when sending.</li>
<li>Username rules: 180 characters; cannot contain <code>@</code>, <code>#</code>, <code>:</code>, or <code>`</code>; cannot contain <code>clyde</code> or <code>discord</code>.</li>
<li>Avatar must be a full <code>http://</code> or <code>https://</code> image URL.</li>
</ul>
</div>
<label for="message_username" class="col-sm-6 col-form-label">Message Username</label>
<input name="message_username" type="text" class="form-control bg-dark border-dark text-muted"
id="message_username" maxlength="80" placeholder="e.g. Power Of The Shell"
{% if message_username %} value="{{- message_username -}}" {% endif %} />
<label for="message_avatar_url" class="col-sm-6 col-form-label">Message Avatar Image</label>
<input name="message_avatar_url" type="url" class="form-control bg-dark border-dark text-muted"
id="message_avatar_url" placeholder="e.g. https://example.com/icon.png"
{% if message_avatar_url %} value="{{- message_avatar_url -}}" {% endif %} />
</div>
</div>
<!-- Add a hidden feed_url field to the form --> <!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{ feed.url }}" /> <input type="hidden" name="feed_url" value="{{ feed.url }}" />
<!-- Submit button --> <!-- Submit button -->