Add options for custom Username & Avatar Image
This commit is contained in:
parent
fc50aed740
commit
f3989eefa9
5 changed files with 161 additions and 11 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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: 1–80 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 -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue