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.
This commit is contained in:
2026-08-13 22:14:07 +08:00
parent edb32c23b4
commit 4580b79d4f
2 changed files with 54 additions and 1 deletions
+3 -1
View File
@@ -569,7 +569,9 @@ fn build_send_task(
chat_id, chat_id,
reply_to_message_id: message.id.0 as i64, reply_to_message_id: message.id.0 as i64,
caption, 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, batch_index: 0,
sent_message_ids: vec![], sent_message_ids: vec![],
source_url, source_url,
+51
View File
@@ -296,6 +296,19 @@ pub fn chunk_media_items<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
.collect() .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<MediaItemPayload>) -> Vec<MediaItemPayload> {
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. /// Exponential backoff with jitter, capped at 30s.
pub fn retry_delay_seconds(attempts: u32) -> f64 { pub fn retry_delay_seconds(attempts: u32) -> f64 {
let jitter: f64 = rand::thread_rng().gen_range(0.2..0.8); 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] #[test]
fn retry_delay_seconds_bounds() { fn retry_delay_seconds_bounds() {
for attempts in 0..10 { for attempts in 0..10 {