From 4580b79d4face7ae7d72aebe828b643c2a7df78d Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Thu, 13 Aug 2026 22:14:07 +0800 Subject: [PATCH] fix(send): order photos first in mixed media groups Telegram's sendMediaGroup requires the first item to be a photo when a group mixes photos and videos; the previous code kept the source-site order, so a mixed post with a video first (twitter media order is not guaranteed) would 400 permanently. photos_first() stable-sorts photos ahead of videos/animations before chunking; within-kind order is kept. --- crates/xmedia-bot/src/handlers.rs | 4 ++- crates/xmedia-bot/src/send.rs | 51 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/xmedia-bot/src/handlers.rs b/crates/xmedia-bot/src/handlers.rs index 19f8114..971586b 100644 --- a/crates/xmedia-bot/src/handlers.rs +++ b/crates/xmedia-bot/src/handlers.rs @@ -569,7 +569,9 @@ fn build_send_task( chat_id, reply_to_message_id: message.id.0 as i64, caption, - media_batches: send::chunk_media_items(items), + // Photos first so a mixed photo+video group starts with a photo + // (Telegram's sendMediaGroup rule); order within each kind is kept. + media_batches: send::chunk_media_items(send::photos_first(items)), batch_index: 0, sent_message_ids: vec![], source_url, diff --git a/crates/xmedia-bot/src/send.rs b/crates/xmedia-bot/src/send.rs index 9706f5a..74049bc 100644 --- a/crates/xmedia-bot/src/send.rs +++ b/crates/xmedia-bot/src/send.rs @@ -296,6 +296,19 @@ pub fn chunk_media_items(items: Vec) -> Vec> { .collect() } +/// Orders media for a Telegram media group: when photos and videos are +/// mixed, the first item must be a photo (Telegram's sendMediaGroup rule). +/// Stable sort keeps the source order within each kind; a lone animation is +/// untouched (it takes the SendAnimation path before this runs). +pub fn photos_first(items: Vec) -> Vec { + let mut items = items; + items.sort_by_key(|item| match item { + MediaItemPayload::Photo { .. } => 0, + MediaItemPayload::Video { .. } | MediaItemPayload::Animation { .. } => 1, + }); + items +} + /// Exponential backoff with jitter, capped at 30s. pub fn retry_delay_seconds(attempts: u32) -> f64 { let jitter: f64 = rand::thread_rng().gen_range(0.2..0.8); @@ -1355,6 +1368,44 @@ mod tests { ); } + #[test] + fn photos_first_orders_photos_before_videos() { + use MediaItemPayload::{Animation, Photo, Video}; + let photo = |u: &str| Photo { + media: u.into(), + has_spoiler: false, + fallback_url: None, + file_id: false, + }; + let video = |u: &str| Video { + media: u.into(), + has_spoiler: false, + thumbnail: None, + fallback_url: None, + file_id: false, + }; + let items = vec![ + video("https://v/1.mp4"), + photo("https://p/1.jpg"), + video("https://v/2.mp4"), + photo("https://p/2.jpg"), + ]; + let ordered = photos_first(items); + // All photos first (stable: p1 before p2), then all videos in order. + let kinds: Vec<&str> = ordered.iter().map(|i| match i { + Photo { media, .. } => media.as_str(), + Video { media, .. } => media.as_str(), + Animation { .. } => unreachable!(), + }).collect(); + assert_eq!( + kinds, + ["https://p/1.jpg", "https://p/2.jpg", "https://v/1.mp4", "https://v/2.mp4"] + ); + // Already-photos-first input is unchanged. + let items = vec![photo("https://p/1.jpg"), video("https://v/1.mp4")]; + assert!(matches!(photos_first(items)[0], Photo { .. })); + } + #[test] fn retry_delay_seconds_bounds() { for attempts in 0..10 {