Add webhook text length stuff
Some checks failed
Test and build Docker image / docker (push) Failing after 15s

This commit is contained in:
Joakim Hellsén 2026-07-02 04:40:38 +02:00
commit 85f25263f3
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
9 changed files with 525 additions and 26 deletions

View file

@ -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]

View file

@ -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)

View file

@ -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

View file

@ -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">

View file

@ -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">