mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
fix(site): truncate captions to Telegram's 1024-char limit
A long tweet text or a pixiv artwork with many tags can exceed Telegram's 1024-char caption cap for HTML parse mode, turning an otherwise fine send into a permanent 400. truncate_caption() cuts at a char boundary (never splitting a multi-byte rune or an HTML entity like &) and appends an ellipsis. Applied in caption_with / caption_from_fields, the link-cache re-send path and the inline-query captions.
This commit is contained in:
@@ -66,7 +66,8 @@ impl Fetched {
|
|||||||
/// HTML-escaped in full, then the (already-escaped) placeholder values
|
/// HTML-escaped in full, then the (already-escaped) placeholder values
|
||||||
/// are substituted — users can structure text but never inject raw HTML
|
/// are substituted — users can structure text but never inject raw HTML
|
||||||
/// or attributes. An empty/unknown format falls back to the built-in
|
/// or attributes. An empty/unknown format falls back to the built-in
|
||||||
/// caption.
|
/// caption. The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's
|
||||||
|
/// caption limit for HTML parse mode).
|
||||||
pub fn caption_with(&self, format: &str) -> String {
|
pub fn caption_with(&self, format: &str) -> String {
|
||||||
match (&self.render_data, format.is_empty()) {
|
match (&self.render_data, format.is_empty()) {
|
||||||
(Some(data), false) => caption_from_fields(
|
(Some(data), false) => caption_from_fields(
|
||||||
@@ -78,7 +79,7 @@ impl Fetched {
|
|||||||
&data.title,
|
&data.title,
|
||||||
&data.tags,
|
&data.tags,
|
||||||
),
|
),
|
||||||
_ => self.caption.clone(),
|
_ => truncate_caption(&self.caption),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +106,37 @@ impl Fetched {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Telegram's caption length limit (chars) for HTML parse mode; longer
|
||||||
|
/// captions are rejected with a 400.
|
||||||
|
pub const MAX_CAPTION_CHARS: usize = 1024;
|
||||||
|
|
||||||
|
/// Truncates a caption to at most [`MAX_CAPTION_CHARS`] chars, appending an
|
||||||
|
/// ellipsis when cut. Backs off to before an unclosed HTML entity (`&`
|
||||||
|
/// without its `;` would be malformed HTML and rejected by Telegram).
|
||||||
|
pub fn truncate_caption(caption: &str) -> String {
|
||||||
|
if caption.chars().count() <= MAX_CAPTION_CHARS {
|
||||||
|
return caption.to_string();
|
||||||
|
}
|
||||||
|
// Leave one char for the ellipsis; floor_char_boundary lands on a char
|
||||||
|
// edge (byte index ≤ MAX-1, so chars ≤ MAX-1).
|
||||||
|
let mut end = caption.floor_char_boundary(MAX_CAPTION_CHARS - 1);
|
||||||
|
// Don't split an entity: if the last '&' before `end` has no closing ';'
|
||||||
|
// inside the kept part, cut before it.
|
||||||
|
if let Some(amp) = caption[..end].rfind('&')
|
||||||
|
&& !caption[amp..end].contains(';')
|
||||||
|
{
|
||||||
|
end = amp;
|
||||||
|
}
|
||||||
|
let mut s = caption[..end].to_string();
|
||||||
|
s.push('…');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a user-supplied caption format from raw (already-escaped) field
|
/// Renders a user-supplied caption format from raw (already-escaped) field
|
||||||
/// values with the same escaping/substitution rules as
|
/// values with the same escaping/substitution rules as
|
||||||
/// [`Fetched::caption_with`]. An empty format returns `built_in` unchanged.
|
/// [`Fetched::caption_with`]. An empty format returns `built_in` unchanged.
|
||||||
|
/// The result is truncated to [`MAX_CAPTION_CHARS`] (Telegram's caption
|
||||||
|
/// limit for HTML parse mode).
|
||||||
pub fn caption_from_fields(
|
pub fn caption_from_fields(
|
||||||
format: &str,
|
format: &str,
|
||||||
built_in: &str,
|
built_in: &str,
|
||||||
@@ -118,15 +147,17 @@ pub fn caption_from_fields(
|
|||||||
tags: &str,
|
tags: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
if format.is_empty() {
|
if format.is_empty() {
|
||||||
return built_in.to_string();
|
return truncate_caption(built_in);
|
||||||
}
|
}
|
||||||
let escaped = html_escape::encode_text(format).into_owned();
|
let escaped = html_escape::encode_text(format).into_owned();
|
||||||
escaped
|
truncate_caption(
|
||||||
|
&escaped
|
||||||
.replace("{url}", url)
|
.replace("{url}", url)
|
||||||
.replace("{author}", author)
|
.replace("{author}", author)
|
||||||
.replace("{author_url}", author_url)
|
.replace("{author_url}", author_url)
|
||||||
.replace("{title}", title)
|
.replace("{title}", title)
|
||||||
.replace("{tags}", tags)
|
.replace("{tags}", tags),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable per-post cache key derived from any supported URL, so variant
|
/// Stable per-post cache key derived from any supported URL, so variant
|
||||||
@@ -406,6 +437,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_caption_keeps_short_text() {
|
||||||
|
assert_eq!(truncate_caption("short"), "short");
|
||||||
|
// Exactly at the limit: untouched.
|
||||||
|
let exact = "x".repeat(MAX_CAPTION_CHARS);
|
||||||
|
assert_eq!(truncate_caption(&exact), exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_caption_cuts_long_text_with_ellipsis() {
|
||||||
|
let long = "x".repeat(MAX_CAPTION_CHARS + 100);
|
||||||
|
let out = truncate_caption(&long);
|
||||||
|
assert!(out.chars().count() <= MAX_CAPTION_CHARS, "len {}", out.chars().count());
|
||||||
|
assert!(out.ends_with('…'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_caption_does_not_split_an_html_entity() {
|
||||||
|
// An entity crossing the cut must not be left half-open (& without ;).
|
||||||
|
let mut long = "a".repeat(MAX_CAPTION_CHARS - 4);
|
||||||
|
long.push_str("&bbbb");
|
||||||
|
let out = truncate_caption(&long);
|
||||||
|
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
|
||||||
|
assert!(!out.contains("&"), "half entity left: {out:?}");
|
||||||
|
assert!(!out.ends_with('&'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_caption_handles_multibyte_boundary() {
|
||||||
|
// Multi-byte chars near the cut must not panic (char-boundary cut).
|
||||||
|
let long = "界".repeat(MAX_CAPTION_CHARS + 10);
|
||||||
|
let out = truncate_caption(&long);
|
||||||
|
assert!(out.chars().count() <= MAX_CAPTION_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unsupported_url_returns_none() {
|
async fn unsupported_url_returns_none() {
|
||||||
let result = fetch("https://example.com/some/article").await;
|
let result = fetch("https://example.com/some/article").await;
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ async fn url_media(bot: Bot, message: &Message, url: &str) {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let caption = if format.is_empty() {
|
let caption = if format.is_empty() {
|
||||||
cached.caption.clone()
|
x_media::site::truncate_caption(&cached.caption)
|
||||||
} else {
|
} else {
|
||||||
x_media::site::caption_from_fields(
|
x_media::site::caption_from_fields(
|
||||||
&format,
|
&format,
|
||||||
@@ -859,6 +859,9 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
|||||||
match x_media::site::fetch(&query.query).await {
|
match x_media::site::fetch(&query.query).await {
|
||||||
Ok(Some(fetched)) => {
|
Ok(Some(fetched)) => {
|
||||||
let mut results: Vec<InlineQueryResult> = Vec::new();
|
let mut results: Vec<InlineQueryResult> = Vec::new();
|
||||||
|
// Inline results have the same 1024-char caption limit as regular
|
||||||
|
// messages; truncate once here for all items.
|
||||||
|
let caption = x_media::site::truncate_caption(&fetched.caption);
|
||||||
for (i, media) in fetched.media.iter().enumerate() {
|
for (i, media) in fetched.media.iter().enumerate() {
|
||||||
let id = format!("{i}");
|
let id = format!("{i}");
|
||||||
let Some(url) = url::Url::parse(media.url()).ok() else {
|
let Some(url) = url::Url::parse(media.url()).ok() else {
|
||||||
@@ -868,7 +871,7 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
|
|||||||
.thumbnail_url()
|
.thumbnail_url()
|
||||||
.and_then(|t| url::Url::parse(t).ok())
|
.and_then(|t| url::Url::parse(t).ok())
|
||||||
.unwrap_or_else(|| url.clone());
|
.unwrap_or_else(|| url.clone());
|
||||||
let caption = fetched.caption.clone();
|
let caption = caption.clone();
|
||||||
let result = match media {
|
let result = match media {
|
||||||
Media::Illustration { .. } => {
|
Media::Illustration { .. } => {
|
||||||
// Inline photo results have their own (smaller) size
|
// Inline photo results have their own (smaller) size
|
||||||
|
|||||||
Reference in New Issue
Block a user