fallback to smaller media when file too large

This commit is contained in:
2026-08-04 16:22:49 +08:00
parent 6fd4edb3c5
commit 93d47752cf
5 changed files with 257 additions and 42 deletions
+18
View File
@@ -14,6 +14,24 @@ impl Media {
Media::Animated { thumbnail_url, .. } => Some(thumbnail_url),
}
}
/// A smaller variant of this media's file (used as the fallback when the
/// primary URL or upload exceeds Telegram's size limits). None when no
/// smaller variant exists (videos, animated gifs).
pub fn smaller_url(&self) -> Option<&str> {
match self {
Media::Illustration {
url,
fallback_url,
thumbnail_url,
..
} => fallback_url
.as_deref()
.or(thumbnail_url.as_deref())
.filter(|smaller| *smaller != url),
Media::Video { .. } | Media::Animated { .. } => None,
}
}
}
#[derive(Debug)]
+13
View File
@@ -195,6 +195,19 @@ async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
/// fetch of a media URL is blocked (hotlink protection), the bot downloads
/// the file itself and uploads it via multipart. Site-appropriate headers:
/// pixiv image hosts need the `Referer` header.
/// Returns the Content-Length of a media URL, or `None` when the server does
/// not report one. Used to check whether a file fits Telegram's size limits
/// before downloading/uploading it.
pub async fn media_size(url: &str) -> Result<Option<u64>, FetchError> {
let mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase();
if lower.contains("pximg.net") {
request = request.header("Referer", "https://www.pixiv.net/");
}
let response = request.send().await?;
Ok(response.content_length())
}
pub async fn download_media(url: &str) -> Result<bytes::Bytes, FetchError> {
let mut request = CLIENT.get(url);
let lower = url.to_ascii_lowercase();
+3 -1
View File
@@ -129,7 +129,9 @@ impl Tweet {
title: None,
url: original_twimg_url(&item.media_url_https),
thumbnail_url: None,
fallback_url: None,
// The param-less base URL is a reduced-size variant;
// used as the fallback when the original is too large.
fallback_url: Some(item.media_url_https.clone()),
}),
"video" => media.push(Media::Video {
title: None,
+15 -3
View File
@@ -322,22 +322,26 @@ fn thumbnail_for(media: &Media) -> Option<String> {
}
fn media_to_payload(media: &Media, sensitive: bool) -> MediaItemPayload {
let fallback_url = media.smaller_url().map(str::to_string);
match media {
// A gif inside a group becomes a video item; a lone gif takes the
// animation path (see url_media).
Media::Illustration { .. } => MediaItemPayload::Photo {
media: media.url().to_string(),
has_spoiler: sensitive,
fallback_url,
},
Media::Video { .. } => MediaItemPayload::Video {
media: media.url().to_string(),
has_spoiler: sensitive,
thumbnail: thumbnail_for(media),
fallback_url,
},
Media::Animated { .. } => MediaItemPayload::Video {
media: media.url().to_string(),
has_spoiler: sensitive,
thumbnail: thumbnail_for(media),
fallback_url,
},
}
}
@@ -502,11 +506,19 @@ pub async fn inline_query_handler(bot: Bot, query: InlineQuery) -> Result<(), Re
.unwrap_or_else(|| url.clone());
let caption = fetched.caption.clone();
let result = match media {
Media::Illustration { .. } => InlineQueryResult::Photo(
InlineQueryResultPhoto::new(id, url, thumbnail)
Media::Illustration { .. } => {
// Inline photo results have their own (smaller) size
// cap; use the reduced variant when one exists.
let photo_url = media
.smaller_url()
.and_then(|u| url::Url::parse(u).ok())
.unwrap_or_else(|| url.clone());
InlineQueryResult::Photo(
InlineQueryResultPhoto::new(id, photo_url, thumbnail)
.caption(caption)
.parse_mode(ParseMode::Html),
),
)
}
Media::Video { .. } => InlineQueryResult::Video(
InlineQueryResultVideo::new(
id,
+196 -26
View File
@@ -25,11 +25,17 @@ pub enum MediaItemPayload {
Photo {
media: String,
has_spoiler: bool,
/// Smaller variant used when the primary media exceeds Telegram's
/// size limits.
#[serde(default)]
fallback_url: Option<String>,
},
Video {
media: String,
has_spoiler: bool,
thumbnail: Option<String>,
#[serde(default)]
fallback_url: Option<String>,
},
Animation {
media: String,
@@ -37,6 +43,16 @@ pub enum MediaItemPayload {
},
}
impl MediaItemPayload {
fn fallback_url(&self) -> Option<&str> {
match self {
MediaItemPayload::Photo { fallback_url, .. }
| MediaItemPayload::Video { fallback_url, .. } => fallback_url.as_deref(),
MediaItemPayload::Animation { .. } => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Task {
@@ -74,7 +90,9 @@ pub enum Task {
}
pub const MAX_MEDIA_GROUP: usize = 9;
pub const MAX_UPLOAD_BYTES: u64 = 50 * 1024 * 1024; // Telegram Bot API upload cap
/// Upload cap (bytes): files above this are not uploaded; the bot falls back
/// to a smaller media URL instead.
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; // 10485760
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items.
pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
@@ -102,6 +120,18 @@ pub fn is_media_fetch_failure(e: &ApiError) -> bool {
MARKERS.iter().any(|marker| description.contains(marker))
}
/// Telegram reported the media file as too large (HTTP 413 on multipart
/// upload, or a "too large" message for URL-fetched media). These errors are
/// handled by the size-check fallback (use a smaller media URL), NOT by a
/// queue retry.
pub fn is_size_error(e: &ApiError) -> bool {
if matches!(e, ApiError::RequestEntityTooLarge) {
return true;
}
let description = e.to_string().to_lowercase();
["too large", "too big"].iter().any(|marker| description.contains(marker))
}
/// Task-free classification of a Telegram request error. The callers attach
/// the (updated) task when building a [`SendError`].
pub enum Classification {
@@ -216,11 +246,13 @@ fn build_media_group(
MediaItemPayload::Photo {
media,
has_spoiler,
..
} => photo_media(input_file_for(media)?, item_caption, *has_spoiler),
MediaItemPayload::Video {
media,
has_spoiler,
thumbnail,
..
} => {
let mut video = video_media(input_file_for(media)?, item_caption, *has_spoiler);
if let (Some(thumb), InputMedia::Video(v)) = (thumbnail, &mut video) {
@@ -231,6 +263,7 @@ fn build_media_group(
MediaItemPayload::Animation {
media,
has_spoiler,
..
} => animation_media(input_file_for(media)?, item_caption, *has_spoiler),
})
})
@@ -258,6 +291,9 @@ fn sniff_ext(bytes: &[u8]) -> &'static str {
enum FallbackError {
Retryable { delay_seconds: f64 },
Permanent { message: String },
/// The downloaded file exceeds the upload cap; the caller falls back to
/// the item's smaller URL.
MediaTooLarge,
}
/// Downloads one media item to a temp file (deleted on drop). Network errors
@@ -282,9 +318,7 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
}
};
if bytes.len() as u64 > MAX_UPLOAD_BYTES {
return Err(FallbackError::Permanent {
message: "media too large".into(),
});
return Err(FallbackError::MediaTooLarge);
}
let ext = sniff_ext(&bytes);
let mut file = tempfile::Builder::new()
@@ -302,7 +336,48 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
Ok(file)
}
/// Download-and-reupload fallback for one media batch.
/// Builds the media group item from an uploaded file.
fn media_from_file(
item: &MediaItemPayload,
path: std::path::PathBuf,
caption: Option<&str>,
) -> InputMedia {
match item {
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(InputFile::file(path), caption, *has_spoiler)
}
MediaItemPayload::Video { has_spoiler, .. } => {
video_media(InputFile::file(path), caption, *has_spoiler)
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(InputFile::file(path), caption, *has_spoiler)
}
}
}
/// Builds the media group item from a (smaller) URL.
fn media_from_url(
item: &MediaItemPayload,
url: &str,
caption: Option<&str>,
) -> Result<InputMedia, String> {
Ok(match item {
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(input_file_for(url)?, caption, *has_spoiler)
}
MediaItemPayload::Video { has_spoiler, .. } => {
video_media(input_file_for(url)?, caption, *has_spoiler)
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(input_file_for(url)?, caption, *has_spoiler)
}
})
}
/// Download-and-reupload fallback for one media batch. Files over the upload
/// cap are not downloaded/uploaded; the item falls back to its smaller URL
/// (which Telegram fetches itself). Returns the fallback-error without the
/// task attached; callers wrap it with the updated task state.
async fn send_batch_via_upload(
bot: &Bot,
chat_id: i64,
@@ -313,22 +388,51 @@ async fn send_batch_via_upload(
let mut files = Vec::new();
let mut items = Vec::new();
for (i, item) in batch.iter().enumerate() {
let file = download_to_temp(item).await?;
let path = file.path().to_path_buf();
let item_caption = if i == 0 { caption } else { None };
let media = match item {
MediaItemPayload::Photo { has_spoiler, .. } => {
photo_media(InputFile::file(path), item_caption, *has_spoiler)
// Size check before downloading/uploading: over the cap, use the
// smaller URL instead of the file.
let too_large = match x_media::site::media_size(item_url(item)).await {
Ok(Some(size)) => size > MAX_UPLOAD_BYTES,
_ => false,
};
let media = if too_large {
match item.fallback_url() {
Some(url) => match media_from_url(item, url, item_caption) {
Ok(media) => media,
Err(message) => {
return Err(FallbackError::Permanent { message });
}
MediaItemPayload::Video { has_spoiler, .. } => {
video_media(InputFile::file(path), item_caption, *has_spoiler)
},
None => {
return Err(FallbackError::Permanent {
message: "media too large".into(),
});
}
MediaItemPayload::Animation { has_spoiler, .. } => {
animation_media(InputFile::file(path), item_caption, *has_spoiler)
}
} else {
match download_to_temp(item).await {
Ok(file) => {
let path = file.path().to_path_buf();
files.push(file);
media_from_file(item, path, item_caption)
}
Err(FallbackError::MediaTooLarge) => match item.fallback_url() {
Some(url) => match media_from_url(item, url, item_caption) {
Ok(media) => media,
Err(message) => {
return Err(FallbackError::Permanent { message });
}
},
None => {
return Err(FallbackError::Permanent {
message: "media too large".into(),
});
}
},
Err(e) => return Err(e),
}
};
items.push(media);
files.push(file);
}
let result = bot
.send_media_group(ChatId(chat_id), items)
@@ -422,7 +526,9 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
);
sent.extend(messages.into_iter().map(|m| m.id.0 as i64));
}
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) => {
Err(RequestError::Api(api))
if is_media_fetch_failure(&api) || is_size_error(&api) =>
{
log::info!(
"Telegram could not fetch media for batch {idx} ({}), downloading and reuploading",
batch
@@ -444,6 +550,7 @@ pub async fn send_media_sequence(bot: &Bot, task: &Task) -> Result<Vec<i64>, Sen
task: updated_sequence_task(task, idx, sent),
});
}
Err(FallbackError::MediaTooLarge) => unreachable!("handled inside upload"),
}
}
Err(e) => {
@@ -507,20 +614,15 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
.await
{
Ok(message) => Ok(vec![message.id.0 as i64]),
Err(RequestError::Api(api)) if is_media_fetch_failure(&api) => {
Err(RequestError::Api(api))
if is_media_fetch_failure(&api) || is_size_error(&api) =>
{
log::info!(
"Telegram could not fetch animation URL, downloading and reuploading: {}",
media_url
);
let file = match download_to_temp(animation).await {
Ok(file) => file,
Err(FallbackError::Retryable { delay_seconds }) => {
return Err(SendError::Retryable { delay_seconds, task: task.clone() });
}
Err(FallbackError::Permanent { message }) => {
return Err(SendError::Permanent { message, task: task.clone() });
}
};
match download_to_temp(animation).await {
Ok(file) => {
let path = file.path().to_path_buf();
match send_animation_inner(
bot,
@@ -536,6 +638,41 @@ pub async fn send_animation(bot: &Bot, task: &Task) -> Result<Vec<i64>, SendErro
Err(e) => Err(classify_to_send_error(&e, task.clone())),
}
}
// Over the upload cap: fall back to the smaller URL.
Err(FallbackError::MediaTooLarge) => match animation.fallback_url() {
Some(url) => match input_file_for(url) {
Ok(file) => {
match send_animation_inner(
bot,
chat_id,
reply_to,
caption,
has_spoiler,
file,
)
.await
{
Ok(message) => Ok(vec![message.id.0 as i64]),
Err(e) => Err(classify_to_send_error(&e, task.clone())),
}
}
Err(message) => {
Err(SendError::Permanent { message, task: task.clone() })
}
},
None => Err(SendError::Permanent {
message: "media too large".into(),
task: task.clone(),
}),
},
Err(FallbackError::Retryable { delay_seconds }) => {
Err(SendError::Retryable { delay_seconds, task: task.clone() })
}
Err(FallbackError::Permanent { message }) => {
Err(SendError::Permanent { message, task: task.clone() })
}
}
}
Err(e) => Err(classify_to_send_error(&e, task.clone())),
}
}
@@ -819,6 +956,36 @@ mod tests {
}
}
#[test]
fn is_size_error_matches_known_errors() {
// 413 upload cap.
let e = ApiError::RequestEntityTooLarge;
assert!(is_size_error(&e), "{e:?}");
// Unknown descriptions with size wording.
for description in [
"Bad Request: file is too large",
"Bad Request: media is too big",
"Bad Request: url file size is too big",
] {
let api = ApiError::Unknown(description.to_string());
assert!(is_size_error(&api), "{description}");
}
// Unrelated errors must not match.
for description in ["Bad Request: WEBPAGE_MEDIA_EMPTY", "Bad Request: message is not modified"] {
let api = ApiError::Unknown(description.to_string());
assert!(!is_size_error(&api), "{description}");
}
}
#[test]
fn media_item_payload_fallback_url_serde_default() {
// Old queued payloads without the field deserialize with None.
let json = serde_json::json!({"kind": "photo", "media": "https://a/b.jpg", "has_spoiler": false});
let photo: MediaItemPayload = serde_json::from_value(json).unwrap();
assert!(matches!(photo, MediaItemPayload::Photo { fallback_url: None, .. }));
assert_eq!(photo.fallback_url(), None);
}
#[test]
fn classification_mapping() {
use teloxide::types::Seconds;
@@ -858,11 +1025,13 @@ mod tests {
vec![MediaItemPayload::Photo {
media: "https://a/b.jpg".into(),
has_spoiler: true,
fallback_url: Some("https://a/b_small.jpg".into()),
}],
vec![MediaItemPayload::Video {
media: "https://a/v.mp4".into(),
has_spoiler: false,
thumbnail: Some("https://a/t.jpg".into()),
fallback_url: None,
}],
],
batch_index: 1,
@@ -900,6 +1069,7 @@ mod tests {
let photo = MediaItemPayload::Photo {
media: "https://a/b.jpg".into(),
has_spoiler: false,
fallback_url: None,
};
let json = serde_json::to_value(&photo).unwrap();
assert_eq!(json["kind"], "photo");