Add webhook text length stuff
Some checks failed
Test and build Docker image / docker (push) Failing after 15s
Some checks failed
Test and build Docker image / docker (push) Failing after 15s
This commit is contained in:
parent
d16fa92e4d
commit
85f25263f3
9 changed files with 525 additions and 26 deletions
|
|
@ -301,6 +301,38 @@ def get_screenshot_layout(reader: Reader, feed: Feed) -> ScreenshotLayout:
|
|||
return "desktop"
|
||||
|
||||
|
||||
def coerce_webhook_text_length_limit(value: JsonValue) -> int:
|
||||
"""Return the supported webhook text length limit for a stored tag value."""
|
||||
if isinstance(value, bool):
|
||||
return 4000
|
||||
if isinstance(value, int):
|
||||
return min(max(value, 1), 4000)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed_value: int = int(value.strip())
|
||||
except ValueError:
|
||||
return 4000
|
||||
return min(max(parsed_value, 1), 4000)
|
||||
return 4000
|
||||
|
||||
|
||||
def get_feed_webhook_text_length_limit(reader: Reader, feed: Feed | str) -> int:
|
||||
"""Resolve how much webhook text a feed may send to Discord.
|
||||
|
||||
Returns:
|
||||
The configured webhook text length limit, normalized to a supported Discord length.
|
||||
"""
|
||||
feed_url: str = str(getattr(feed, "url", feed))
|
||||
try:
|
||||
value = reader.get_tag(feed, "webhook_text_length_limit", 4000)
|
||||
|
||||
except ReaderError:
|
||||
logger.exception("Error getting %s tag for feed: %s", "webhook_text_length_limit", feed_url)
|
||||
return 4000
|
||||
|
||||
return coerce_webhook_text_length_limit(value)
|
||||
|
||||
|
||||
def coerce_media_gallery_image_limit(value: JsonValue) -> int: # noqa: PLR0911
|
||||
"""Return the supported media gallery image limit for a stored tag value."""
|
||||
if isinstance(value, bool):
|
||||
|
|
@ -1090,7 +1122,11 @@ def create_text_webhook(
|
|||
if not webhook_message:
|
||||
webhook_message = "No message found."
|
||||
|
||||
webhook_message = truncate_webhook_message(webhook_message)
|
||||
webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, entry.feed)
|
||||
webhook_message = truncate_webhook_message(
|
||||
webhook_message,
|
||||
max_content_length=webhook_text_length_limit,
|
||||
)
|
||||
return DiscordWebhook(url=webhook_url, content=webhook_message, rate_limit_retry=True)
|
||||
|
||||
|
||||
|
|
@ -1345,21 +1381,26 @@ def send_discord_quest_notification(entry: Entry, webhook_url: str, reader: Read
|
|||
logger.info("No quest notification found in entry: %s", entry.id)
|
||||
|
||||
|
||||
def set_description(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None:
|
||||
def set_description(
|
||||
custom_embed: CustomEmbed,
|
||||
discord_embed: DiscordEmbed,
|
||||
*,
|
||||
max_description_length: int = 2000,
|
||||
) -> 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.
|
||||
max_description_length: The maximum embed description length to send.
|
||||
"""
|
||||
# Its actually 2048, but we will use 2000 to be safe.
|
||||
max_description_length: int = 2000
|
||||
# Discord allows 2048, but we keep a small safety margin by default.
|
||||
embed_description: str = custom_embed.description
|
||||
embed_description = (
|
||||
f"{embed_description[:max_description_length]}..."
|
||||
if len(embed_description) > max_description_length
|
||||
else embed_description
|
||||
)
|
||||
if len(embed_description) > max_description_length:
|
||||
if max_description_length <= 3: # noqa: PLR2004
|
||||
embed_description = embed_description[:max_description_length]
|
||||
else:
|
||||
embed_description = f"{embed_description[: max_description_length - 3]}..."
|
||||
discord_embed.set_description(embed_description) if embed_description else None
|
||||
|
||||
|
||||
|
|
@ -1550,19 +1591,29 @@ def get_entry_media_gallery_items(
|
|||
return media_items[:image_limit]
|
||||
|
||||
|
||||
def truncate_component_text(content: str) -> str:
|
||||
def truncate_component_text(
|
||||
content: str,
|
||||
*,
|
||||
max_text_display_length: int = 4000,
|
||||
) -> 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
|
||||
if max_text_display_length <= 3: # noqa: PLR2004
|
||||
return content[:max_text_display_length]
|
||||
return f"{content[: max_text_display_length - 3]}..."
|
||||
|
||||
|
||||
def get_component_text_display_content(custom_embed: CustomEmbed, entry: Entry) -> str:
|
||||
def get_component_text_display_content(
|
||||
custom_embed: CustomEmbed,
|
||||
entry: Entry,
|
||||
*,
|
||||
max_text_length: int = 4000,
|
||||
) -> str:
|
||||
"""Build markdown text for a Components V2 Text Display.
|
||||
|
||||
Returns:
|
||||
|
|
@ -1592,7 +1643,7 @@ def get_component_text_display_content(custom_embed: CustomEmbed, entry: Entry)
|
|||
fallback_text = f"[{fallback_text}]({entry.link})"
|
||||
parts.append(fallback_text)
|
||||
|
||||
return truncate_component_text("\n\n".join(parts))
|
||||
return truncate_component_text("\n\n".join(parts), max_text_display_length=max_text_length)
|
||||
|
||||
|
||||
def create_media_gallery_component(media_items: list[JsonObject]) -> JsonObject:
|
||||
|
|
@ -1619,6 +1670,8 @@ def create_components_v2_webhook(
|
|||
entry: Entry,
|
||||
custom_embed: CustomEmbed,
|
||||
media_items: list[JsonObject],
|
||||
*,
|
||||
max_text_length: int,
|
||||
) -> DiscordWebhook:
|
||||
"""Create a Components V2 webhook with text and a media gallery.
|
||||
|
||||
|
|
@ -1628,7 +1681,11 @@ def create_components_v2_webhook(
|
|||
components: list[JsonValue] = [
|
||||
{
|
||||
"type": 10,
|
||||
"content": get_component_text_display_content(custom_embed, entry),
|
||||
"content": get_component_text_display_content(
|
||||
custom_embed,
|
||||
entry,
|
||||
max_text_length=max_text_length,
|
||||
),
|
||||
},
|
||||
create_media_gallery_component(media_items),
|
||||
]
|
||||
|
|
@ -1661,6 +1718,7 @@ def create_embed_webhook( # noqa: C901, PLR0912
|
|||
# Get the embed data from the database.
|
||||
custom_embed: CustomEmbed = replace_tags_in_embed(feed=feed, entry=entry, reader=reader)
|
||||
media_gallery_image_limit: int = get_feed_media_gallery_image_limit(reader, feed)
|
||||
webhook_text_length_limit: int = get_feed_webhook_text_length_limit(reader, feed)
|
||||
if media_gallery_image_limit == 0:
|
||||
custom_embed.image_url = ""
|
||||
custom_embed.thumbnail_url = ""
|
||||
|
|
@ -1671,11 +1729,21 @@ def create_embed_webhook( # noqa: C901, PLR0912
|
|||
image_limit=media_gallery_image_limit,
|
||||
)
|
||||
if media_gallery_items:
|
||||
return create_components_v2_webhook(webhook_url, entry, custom_embed, media_gallery_items)
|
||||
return create_components_v2_webhook(
|
||||
webhook_url,
|
||||
entry,
|
||||
custom_embed,
|
||||
media_gallery_items,
|
||||
max_text_length=webhook_text_length_limit,
|
||||
)
|
||||
|
||||
discord_embed: DiscordEmbed = DiscordEmbed()
|
||||
|
||||
set_description(custom_embed=custom_embed, discord_embed=discord_embed)
|
||||
set_description(
|
||||
custom_embed=custom_embed,
|
||||
discord_embed=discord_embed,
|
||||
max_description_length=min(webhook_text_length_limit, 2000),
|
||||
)
|
||||
set_title(custom_embed=custom_embed, discord_embed=discord_embed)
|
||||
|
||||
custom_embed_author_url: str | None = custom_embed.author_url
|
||||
|
|
@ -1889,20 +1957,28 @@ def should_send_embed_check(reader: Reader, entry: Entry) -> bool:
|
|||
return get_entry_delivery_mode(reader, entry) == "embed"
|
||||
|
||||
|
||||
def truncate_webhook_message(webhook_message: str) -> str:
|
||||
def truncate_webhook_message(
|
||||
webhook_message: str,
|
||||
*,
|
||||
max_content_length: int = 4000,
|
||||
) -> str:
|
||||
"""Truncate the webhook message if it is too long.
|
||||
|
||||
Args:
|
||||
webhook_message (str): The webhook message to truncate.
|
||||
max_content_length: The maximum number of characters Discord should receive.
|
||||
|
||||
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
|
||||
if len(webhook_message) <= max_content_length:
|
||||
return webhook_message
|
||||
if max_content_length <= 3: # noqa: PLR2004
|
||||
return webhook_message[:max_content_length]
|
||||
|
||||
head_length = (max_content_length - 3) // 2
|
||||
tail_length = max_content_length - 3 - head_length
|
||||
return f"{webhook_message[:head_length]}...{webhook_message[-tail_length:]}"
|
||||
|
||||
|
||||
def get_raw_autodiscover_links(reader: Reader, feed_url: str) -> object | None:
|
||||
|
|
@ -1995,6 +2071,14 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None:
|
|||
"media_gallery_image_limit",
|
||||
cast("JSONType", 1),
|
||||
)
|
||||
global_webhook_text_length_limit: int = coerce_webhook_text_length_limit(
|
||||
cast("JsonValue", reader.get_tag((), "webhook_text_length_limit", 4000)),
|
||||
)
|
||||
reader.set_tag(
|
||||
clean_feed_url,
|
||||
"webhook_text_length_limit",
|
||||
cast("JSONType", global_webhook_text_length_limit),
|
||||
)
|
||||
|
||||
# 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]
|
||||
|
|
|
|||
|
|
@ -54,11 +54,13 @@ from discord_rss_bot.custom_message import save_embed
|
|||
from discord_rss_bot.feeds import FeedUpdateError
|
||||
from discord_rss_bot.feeds import SentWebhookRecord
|
||||
from discord_rss_bot.feeds import coerce_media_gallery_image_limit
|
||||
from discord_rss_bot.feeds import coerce_webhook_text_length_limit
|
||||
from discord_rss_bot.feeds import create_feed
|
||||
from discord_rss_bot.feeds import extract_domain
|
||||
from discord_rss_bot.feeds import feed_saves_sent_webhooks
|
||||
from discord_rss_bot.feeds import get_feed_delivery_mode
|
||||
from discord_rss_bot.feeds import get_feed_media_gallery_image_limit
|
||||
from discord_rss_bot.feeds import get_feed_webhook_text_length_limit
|
||||
from discord_rss_bot.feeds import get_screenshot_layout
|
||||
from discord_rss_bot.feeds import get_sent_webhook_records
|
||||
from discord_rss_bot.feeds import send_entry_to_discord
|
||||
|
|
@ -1477,6 +1479,38 @@ async def post_set_feed_media_gallery_image_limit(
|
|||
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/set_feed_webhook_text_length_limit")
|
||||
async def post_set_feed_webhook_text_length_limit(
|
||||
feed_url: Annotated[str, Form()],
|
||||
text_length_limit: Annotated[int, Form()],
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> RedirectResponse:
|
||||
"""Set the maximum webhook text length for a feed.
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to the feed page.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the feed does not exist.
|
||||
"""
|
||||
clean_feed_url: str = feed_url.strip()
|
||||
clean_text_length_limit: int = coerce_webhook_text_length_limit(text_length_limit)
|
||||
clean_text_length_limit_json: JSONType = cast("JSONType", clean_text_length_limit)
|
||||
|
||||
try:
|
||||
reader.get_feed(clean_feed_url)
|
||||
except FeedNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="Feed not found") from e
|
||||
|
||||
reader.set_tag(
|
||||
clean_feed_url,
|
||||
"webhook_text_length_limit",
|
||||
clean_text_length_limit_json,
|
||||
)
|
||||
commit_state_change(reader, f"Set webhook text length limit to {clean_text_length_limit} for {clean_feed_url}")
|
||||
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/set_update_interval")
|
||||
async def post_set_update_interval(
|
||||
feed_url: Annotated[str, Form()],
|
||||
|
|
@ -1681,6 +1715,28 @@ async def post_set_global_delivery_mode(
|
|||
return RedirectResponse(url="/settings", status_code=303)
|
||||
|
||||
|
||||
@app.post("/set_global_webhook_text_length_limit")
|
||||
async def post_set_global_webhook_text_length_limit(
|
||||
text_length_limit: Annotated[int, Form()],
|
||||
reader: Annotated[Reader, Depends(get_reader_dependency)],
|
||||
) -> RedirectResponse:
|
||||
"""Set the global default webhook text length limit for newly added feeds.
|
||||
|
||||
Args:
|
||||
text_length_limit: The max webhook text length.
|
||||
reader: The Reader instance.
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to the settings page.
|
||||
"""
|
||||
clean_text_length_limit: int = coerce_webhook_text_length_limit(text_length_limit)
|
||||
clean_text_length_limit_json: JSONType = cast("JSONType", clean_text_length_limit)
|
||||
|
||||
reader.set_tag((), "webhook_text_length_limit", clean_text_length_limit_json) # pyright: ignore[reportArgumentType]
|
||||
commit_state_change(reader, f"Set global webhook text length limit to {clean_text_length_limit}")
|
||||
return RedirectResponse(url="/settings", status_code=303)
|
||||
|
||||
|
||||
@app.get("/add", response_class=HTMLResponse)
|
||||
def get_add(
|
||||
request: Request,
|
||||
|
|
@ -1796,6 +1852,8 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
|
|||
"has_whitelist_filters": has_whitelist_filters,
|
||||
"media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed),
|
||||
"max_media_gallery_items": 10,
|
||||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
|
@ -1860,6 +1918,8 @@ async def get_feed( # noqa: C901, PLR0912, PLR0914, PLR0915
|
|||
"has_whitelist_filters": has_whitelist_filters,
|
||||
"media_gallery_image_limit": get_feed_media_gallery_image_limit(reader, feed),
|
||||
"max_media_gallery_items": 10,
|
||||
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="feed.html", context=context)
|
||||
|
|
@ -2071,6 +2131,10 @@ async def get_settings(
|
|||
if global_delivery_mode not in {"embed", "text"}:
|
||||
global_delivery_mode = "embed"
|
||||
|
||||
global_webhook_text_length_limit: int = coerce_webhook_text_length_limit(
|
||||
reader.get_tag((), "webhook_text_length_limit", 4000)
|
||||
)
|
||||
|
||||
# Get all feeds with their intervals
|
||||
feeds: Iterable[Feed] = reader.get_feeds()
|
||||
feed_intervals = []
|
||||
|
|
@ -2094,6 +2158,8 @@ async def get_settings(
|
|||
"global_interval": global_interval,
|
||||
"global_delivery_mode": global_delivery_mode,
|
||||
"global_screenshot_layout": global_screenshot_layout,
|
||||
"global_webhook_text_length_limit": global_webhook_text_length_limit,
|
||||
"max_webhook_text_length_limit": 4000,
|
||||
"feed_intervals": feed_intervals,
|
||||
}
|
||||
return templates.TemplateResponse(request=request, name="settings.html", context=context)
|
||||
|
|
|
|||
|
|
@ -85,4 +85,8 @@ def get_reader(custom_location: Path | None = None) -> Reader:
|
|||
if reader.get_tag((), "delivery_mode", None) is None:
|
||||
reader.set_tag((), "delivery_mode", "embed") # pyright: ignore[reportArgumentType]
|
||||
|
||||
# Set the default webhook text length limit for new feeds if not already configured.
|
||||
if reader.get_tag((), "webhook_text_length_limit", None) is None:
|
||||
reader.set_tag((), "webhook_text_length_limit", 4000) # pyright: ignore[reportArgumentType]
|
||||
|
||||
return reader
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge status-chip bg-secondary">Text limit: {{ webhook_text_length_limit }} chars</span>
|
||||
</div>
|
||||
</div>
|
||||
<section class="mt-4 pt-3 border-top border-secondary-subtle">
|
||||
|
|
@ -102,6 +103,7 @@
|
|||
{% elif delivery_mode == "screenshot" %}
|
||||
<li>Screenshot layout: {{ screenshot_layout }}.</li>
|
||||
{% endif %}
|
||||
<li>Webhook text is truncated to {{ webhook_text_length_limit }} characters.</li>
|
||||
<li>
|
||||
Filters:
|
||||
{% if has_blacklist_filters and has_whitelist_filters %}
|
||||
|
|
@ -284,6 +286,38 @@
|
|||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="mt-4 pt-3 border-top border-secondary-subtle">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<h3 class="h6 text-uppercase text-muted mb-0">Text Delivery</h3>
|
||||
<span class="badge {{ 'bg-info' if webhook_text_length_limit < max_webhook_text_length_limit else 'bg-secondary' }}">
|
||||
Max {{ webhook_text_length_limit }} characters
|
||||
</span>
|
||||
</div>
|
||||
<p id="textDeliveryHelp" class="text-muted mb-3">
|
||||
Limit message length. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters.
|
||||
</p>
|
||||
<form action="/set_feed_webhook_text_length_limit"
|
||||
method="post"
|
||||
class="mb-0">
|
||||
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
|
||||
<div class="d-flex flex-wrap align-items-end gap-3">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-2" for="text_length_limit">Max characters</label>
|
||||
<input id="text_length_limit"
|
||||
class="form-control form-control-sm bg-dark border-dark text-light"
|
||||
type="number"
|
||||
name="text_length_limit"
|
||||
min="1"
|
||||
max="{{ max_webhook_text_length_limit }}"
|
||||
step="1"
|
||||
value="{{ webhook_text_length_limit }}"
|
||||
aria-describedby="textDeliveryHelp"
|
||||
required />
|
||||
</div>
|
||||
<button class="btn btn-outline-light btn-sm" type="submit">Save text limit</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mt-4 pt-3 border-top border-secondary-subtle">
|
||||
<h3 class="h6 text-uppercase text-muted mb-3">Customization</h3>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Settings | discord-rss-bot
|
||||
{% endblock title %}
|
||||
{% block description %}
|
||||
Adjust default update intervals, delivery modes, and screenshot layout for feeds managed by your bot.
|
||||
Adjust default update intervals, delivery modes, screenshot layout, and webhook text limits for feeds managed by your bot.
|
||||
{% endblock description %}
|
||||
{% block content %}
|
||||
<section>
|
||||
|
|
@ -78,6 +78,28 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form action="/set_global_webhook_text_length_limit"
|
||||
method="post"
|
||||
class="mt-4">
|
||||
<div class="settings-form-row mb-2">
|
||||
<label for="global_text_length_limit" class="form-label mb-1">Default text limit for new feeds</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input id="global_text_length_limit"
|
||||
type="number"
|
||||
class="form-control settings-input"
|
||||
name="text_length_limit"
|
||||
min="1"
|
||||
max="{{ max_webhook_text_length_limit }}"
|
||||
value="{{ global_webhook_text_length_limit }}"
|
||||
required />
|
||||
<button class="btn btn-primary px-4" type="submit">Save</button>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
New feeds inherit this value. Existing feeds keep their current
|
||||
per-feed text limit. Text mode allows values up to 4000 characters. Embeds are capped at 2000 characters.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mt-5">
|
||||
<div class="text-light">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue